Apache Shiro 权限绕过(CVE-2020-13933)分析复现
Ebounce
撰写于 2020年 09月 26 日

Apache Shiro 权限绕过(CVE-2020-13933)

漏洞影响范围

Apache Shiro 版本 < 1.6.0

漏洞环境搭建

下载官方直接写好的源码github-apache-Shiro:
<!--more-->

由于漏洞版本为 Apache shiro < 1.6.0 所以我们直接下载tag中的1.5.3版本即可。

下载好对应源码之后,解压导入idea项目中,idea会为我们安装好pom.xml中的依赖(该过程有点长,请耐心等待),安装好后,选择WEBAPP启动,(这里会访问外网资源,由于众所周知的原因访问比较慢,可以选择断网调试,或者开飞机)

看到该页面,就说明测试环境已经搭建完成了:

环境改造

从漏洞报告来看,应该是Shiro动态URL的解析出现了问题,导致了未授权访问的情况,因此为了方便查看,我们新添加一个Controller,作为测试页面。

TestController.java:

package org.apache.shiro.samples;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class TestController {
    @RequestMapping(value = "/test/{id}",method = RequestMethod.GET)
    public String test(@PathVariable String id, Model model){
        model.addAttribute("id",id);
        return "test";
    }
}

test.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Vuln Test Page</title>
</head>
<body>
    <h1 th:text="'Auth Page Test:'+${id}"></h1>
</body>
</html>

当然别忘了将这个路由加入FilterChain:

    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/login.html", "authc"); // need to accept POSTs from the login form
        chainDefinition.addPathDefinition("/test/*","authc"); 
        // 新添加的需要验证的路由,注意这里只使用了一个*号
        chainDefinition.addPathDefinition("/logout", "logout");
        return chainDefinition;
    }

项目结构如下:

最后效果只要如下图就算可以开始复现了,当未登录时,自动跳转登录界面:


登录后可以正常访问:

PS:有关Shiro-FilterChain是什么

如果简单阅读这个Shiro的官方例子,你会发现此处登录页面提交参数之后,完全没有在Controller处进行UsernamePasswordToken函数进行验证,那么自然会产生参数是在哪里处理这个疑问。由于使用的是Springboot,很自然就明白应该是在filter处进行了参数处理,我们可以将FIlterChain简单理解成为Filter的列表,它告诉应用在什么时候,该调用哪个FIlterShiro会对Servlet容器进行代理,先执行自己的FIlterChain,再执行ServletFIlterChain,下面这些代码就是在添加Shiro:

    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/login.html", "authc"); // need to accept POSTs from the login form
        chainDefinition.addPathDefinition("/test/*","authc"); // 新添加的需要验证的路由,一个星号表示弱匹配
        chainDefinition.addPathDefinition("/logout", "logout");
        return chainDefinition;
    }

如果跟进addPathDefinition这个函数,可以发现最后是返回一个map类型的参数

实质上和网上目前的复现方式使用map.put设置路由是差不多的。

Shiro有自己的默认FilterChain,也可以自己实现,默认FilterChain及其对应功能如下图(抄的网上的表)

漏洞复现

其实漏洞复现很简单,只需要对;,进行url编码即可,如下图:

http://<Your-server>/test/%3B1<Any String>/

需要注意的是,如果我们此处路由为强匹配,则不会出现权限绕过的情况:

具体为什么,见后文强匹配与弱匹配的区别

漏洞分析

漏洞成因

Shiro处理

此处漏洞成因和Shiro爆出的CVE-2020-11989类似,是由于ShiroSpring对URL解析的差异造成的,调试过程中我们顺便梳理一下匹配的流程。根据前文所提到的FilterChain,我们能够找到Shiro对路径匹配的FIlter

Shiro的处理URL入口在这里/org/apache/shiro/shiro-web/1.5.3/shiro-web-1.5.3.jar!/org/apache/shiro/web/util/WebUtils.class

后面改用windows环境调试了,所以看着主题不一样2333

这里注释也写的很清楚,该函数会返回web应用程序中当前访问的路径,自然路径解析是从这里开始的,跟一下,主要注意的是,我们发现这里已经进行过一次URL编码了

removeSemicolon会去除掉;这个字符

最后结果是,将;号后面的内容去除:

而经过处理,最后参与匹配的字符串为:

然后shiro仅仅只会和源码中写明了FilterChain的路由进行匹配,毕竟shiro只是进行鉴权工作。

其他不用shiro参与的处理,需要调用Spring自己Filter

在此处Shiro完成了自己的任务,将移交给spring的Filter(注意函数栈出现了spring)

因此最后Shiro得到的路由为/test,这个路由在我们源码中不存在,会移交给error处理路由,返回404,这个是任何用户有权访问的。

也因为如此shiro判断可以放权访问。

Spring的处理

一路疯狂F7我们发现SpringBoot的处理URL处在这:

继续跟发现会在此处进行URL字符处理:

随后根据得到的URL,遍历所有Controller寻找匹配

梳理了一下,共五次匹配操作:

  1. First Match

这里第一次匹配与Accountinfo进行比较,查看是不是该路由。

  1. Second Match

第二次匹配与/比较,看看是不是该路由。

  1. Third Match

第三次匹配与login.html比较,看是不是该路由。

  1. Fourth Match

第四次匹配与error路由匹配,看是不是该路由。

  1. Fifth Match

第五次匹配和 GET/test/{id},发现匹配上了,直接返回结果。

细心的同学会发现这不是和我们Controller定义的顺序一模一样吗?

我们在第五次匹配处一路F8看看。发现最后第五次匹配成功后会返回对应的路由:

从这里可知最后spring得到的路由为/test/;Bypass,而之前Shiro已经放权完成,因此我们可以正常访问该路由。

不同的处理方式

Shiro也存在和Spring处理URL的同名函数decodeAndCleanUriString,函数体如下:

这里Shiro的处理是先进行url解码,然后再对;进行处理

Spring函数体如下:

Spring则完全相反,首先进行了;号的处理,然后再进行URL解码。

像往常一样,一张图进行总结:

强匹配与弱匹配的区别

从漏洞的整体流程来看,其实我们可以把关注点放在shiroAntPath处理引擎上,正是这一步的判断导致了越权

代码位置org.apache.shiro.util.AntPathMatcher#doMatch

  • 弱匹配的情况

函数体如下,我将常量进行了替换,看着方便一点:

protected boolean doMatch(String pattern, String path, boolean fullMatch) {
        if (path.startsWith("/") != pattern.startsWith("/")) {
            return false;
        }

        String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, "/");
        String[] pathDirs = StringUtils.tokenizeToStringArray(path, "/");
//通过"/"符号划分数组
        int pattIdxStart = 0;
        int pattIdxEnd = pattDirs.length - 1;
        int pathIdxStart = 0;
        int pathIdxEnd = pathDirs.length - 1;
    
        // Match all elements up to the first **
        while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
            String patDir = pattDirs[pattIdxStart];
            //patDir = "test"
            if ("**".equals(patDir)) {
                break;
            }
            if (!matchStrings(patDir, pathDirs[pathIdxStart])) {
                return false;
            }
            //matchStrings(patDir, pathDirs[pathIdxStart] = true
            pattIdxStart++; // pattIdxStart=1
            pathIdxStart++;// pathIdxStart=1
        }

        if (pathIdxStart > pathIdxEnd) {
            // 1 > 0 走这个分支
            // Path is exhausted, only match if rest of pattern is * or **'s
            if (pattIdxStart > pattIdxEnd) {
                // 1 > 1 False 不走该分支
                return (pattern.endsWith("/") ?
                        path.endsWith("/") : !path.endsWith("/"));
            }
            if (!fullMatch) {
                // 初始传参fullMatch = true 不走该分支
                return true;
            }
            if (pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") &&
                    path.endsWith("/")) {
                // false
                return true;
            }
            //走下面这个循环
            for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
                if (!pattDirs[i].equals("**")) {
                    // pattDirs里的元素均不满足
                    return false; // <-函数将在这里退出
                }
            }
           return true;
    .....
}
    /*
     * 因此通过该函数没有匹配成功,会交给error或者shiro自带的错误处理器处理(错误为404没有匹配,即找不到该路由)
     * 一般也不会有给错误页面设置权限的需求,所以该请求可放行
    */
  • 强匹配的情况

前面匹配的流程和弱匹配一模一样除了这一步:

            for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
                if (!pattDirs[i].equals("**")) {
                    // pattDirs里的1号元素满足 => 该判断返回!true 跳出循环
                    return false;
                }
            }
           return true;<-函数将在这里退出

这里由于能够匹配到**,所以退出该循环,即匹配成功,这时候Shiro会判断该路由,是否有权限访问,这个路由我们是用auth标记的,因此该用户无权访问,即不放行该请求,此处shiro进行的操作是,将访问路由替换为登陆路由:

Shiro的修复方式

我们比较一下shiro-web-1.5.3.jarshiro-web1.6.0.jar两个jar包看看shiro是怎么修复的,我们使用相同的payload,进行访问发现1.6.0返回了400而非404,

翻看shiro-web-1.6.0.jar,发现WebUtilAntPathMather没有变化,最终在filter文件夹中找到了多了一个InvalidRequestFilter

image-20200926114859836

该类注释为,从源码来看过滤是默认开启的,满足Secure By Default原则:

很显然是为了修复权限绕过而做的了,我们看看源码是如何过滤的:

public class InvalidRequestFilter extends AccessControlFilter {

    private static final List<String> SEMICOLON = Collections.unmodifiableList(Arrays.asList(";", "%3b", "%3B"));

    private static final List<String> BACKSLASH = Collections.unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));
//这里对分号和反斜杠的URL编码进行了标记
    private boolean blockSemicolon = true;

    private boolean blockBackslash = true;

    private boolean blockNonAscii = true;

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        String uri = WebUtils.toHttp(request).getRequestURI();
        return !containsSemicolon(uri)
            && !containsBackslash(uri)
            && !containsNonAsciiCharacters(uri);
    }
    //分别判断是分号,反斜杠,还是不可打印字符的情况,如果检测到了就由下面这个函数  返回
        @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        WebUtils.toHttp(response).sendError(400, "Invalid request");
        return false;
    }
    //判断不可打印的字符采用了类似白名单的模式,个人感觉修复是比较彻底的。
        private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
        int length = uri.length();
        for (int i = 0; i < length; i++) {
            char c = uri.charAt(i);
            if (c < '\u0020' || c > '\u007e') {
                return false;
            }
        }
        return true;
    }

参考链接

Apache Shiro 权限绕过漏洞 CVE-2020-13933

shiro CVE-2020-11989&CVE-2020-13933复现分析

shiro < 1.6.0的认证绕过漏洞分析(CVE-2020-13933)

Apache Shiro 权限绕过(CVE-2020-13933)分析复现

Apache Shiro 权限绕过(CVE-2020-13933)

漏洞影响范围

Apache Shiro 版本 < 1.6.0

漏洞环境搭建

下载官方直接写好的源码github-apache-Shiro:
<!--more-->

由于漏洞版本为 Apache shiro < 1.6.0 所以我们直接下载tag中的1.5.3版本即可。

下载好对应源码之后,解压导入idea项目中,idea会为我们安装好pom.xml中的依赖(该过程有点长,请耐心等待),安装好后,选择WEBAPP启动,(这里会访问外网资源,由于众所周知的原因访问比较慢,可以选择断网调试,或者开飞机)

看到该页面,就说明测试环境已经搭建完成了:

环境改造

从漏洞报告来看,应该是Shiro动态URL的解析出现了问题,导致了未授权访问的情况,因此为了方便查看,我们新添加一个Controller,作为测试页面。

TestController.java:

package org.apache.shiro.samples;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class TestController {
    @RequestMapping(value = "/test/{id}",method = RequestMethod.GET)
    public String test(@PathVariable String id, Model model){
        model.addAttribute("id",id);
        return "test";
    }
}

test.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Vuln Test Page</title>
</head>
<body>
    <h1 th:text="'Auth Page Test:'+${id}"></h1>
</body>
</html>

当然别忘了将这个路由加入FilterChain:

    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/login.html", "authc"); // need to accept POSTs from the login form
        chainDefinition.addPathDefinition("/test/*","authc"); 
        // 新添加的需要验证的路由,注意这里只使用了一个*号
        chainDefinition.addPathDefinition("/logout", "logout");
        return chainDefinition;
    }

项目结构如下:

最后效果只要如下图就算可以开始复现了,当未登录时,自动跳转登录界面:


登录后可以正常访问:

PS:有关Shiro-FilterChain是什么

如果简单阅读这个Shiro的官方例子,你会发现此处登录页面提交参数之后,完全没有在Controller处进行UsernamePasswordToken函数进行验证,那么自然会产生参数是在哪里处理这个疑问。由于使用的是Springboot,很自然就明白应该是在filter处进行了参数处理,我们可以将FIlterChain简单理解成为Filter的列表,它告诉应用在什么时候,该调用哪个FIlterShiro会对Servlet容器进行代理,先执行自己的FIlterChain,再执行ServletFIlterChain,下面这些代码就是在添加Shiro:

    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/login.html", "authc"); // need to accept POSTs from the login form
        chainDefinition.addPathDefinition("/test/*","authc"); // 新添加的需要验证的路由,一个星号表示弱匹配
        chainDefinition.addPathDefinition("/logout", "logout");
        return chainDefinition;
    }

如果跟进addPathDefinition这个函数,可以发现最后是返回一个map类型的参数

实质上和网上目前的复现方式使用map.put设置路由是差不多的。

Shiro有自己的默认FilterChain,也可以自己实现,默认FilterChain及其对应功能如下图(抄的网上的表)

漏洞复现

其实漏洞复现很简单,只需要对;,进行url编码即可,如下图:

http://<Your-server>/test/%3B1<Any String>/

需要注意的是,如果我们此处路由为强匹配,则不会出现权限绕过的情况:

具体为什么,见后文强匹配与弱匹配的区别

漏洞分析

漏洞成因

Shiro处理

此处漏洞成因和Shiro爆出的CVE-2020-11989类似,是由于ShiroSpring对URL解析的差异造成的,调试过程中我们顺便梳理一下匹配的流程。根据前文所提到的FilterChain,我们能够找到Shiro对路径匹配的FIlter

Shiro的处理URL入口在这里/org/apache/shiro/shiro-web/1.5.3/shiro-web-1.5.3.jar!/org/apache/shiro/web/util/WebUtils.class

后面改用windows环境调试了,所以看着主题不一样2333

这里注释也写的很清楚,该函数会返回web应用程序中当前访问的路径,自然路径解析是从这里开始的,跟一下,主要注意的是,我们发现这里已经进行过一次URL编码了

removeSemicolon会去除掉;这个字符

最后结果是,将;号后面的内容去除:

而经过处理,最后参与匹配的字符串为:

然后shiro仅仅只会和源码中写明了FilterChain的路由进行匹配,毕竟shiro只是进行鉴权工作。

其他不用shiro参与的处理,需要调用Spring自己Filter

在此处Shiro完成了自己的任务,将移交给spring的Filter(注意函数栈出现了spring)

因此最后Shiro得到的路由为/test,这个路由在我们源码中不存在,会移交给error处理路由,返回404,这个是任何用户有权访问的。

也因为如此shiro判断可以放权访问。

Spring的处理

一路疯狂F7我们发现SpringBoot的处理URL处在这:

继续跟发现会在此处进行URL字符处理:

随后根据得到的URL,遍历所有Controller寻找匹配

梳理了一下,共五次匹配操作:

  1. First Match

这里第一次匹配与Accountinfo进行比较,查看是不是该路由。

  1. Second Match

第二次匹配与/比较,看看是不是该路由。

  1. Third Match

第三次匹配与login.html比较,看是不是该路由。

  1. Fourth Match

第四次匹配与error路由匹配,看是不是该路由。

  1. Fifth Match

第五次匹配和 GET/test/{id},发现匹配上了,直接返回结果。

细心的同学会发现这不是和我们Controller定义的顺序一模一样吗?

我们在第五次匹配处一路F8看看。发现最后第五次匹配成功后会返回对应的路由:

从这里可知最后spring得到的路由为/test/;Bypass,而之前Shiro已经放权完成,因此我们可以正常访问该路由。

不同的处理方式

Shiro也存在和Spring处理URL的同名函数decodeAndCleanUriString,函数体如下:

这里Shiro的处理是先进行url解码,然后再对;进行处理

Spring函数体如下:

Spring则完全相反,首先进行了;号的处理,然后再进行URL解码。

像往常一样,一张图进行总结:

强匹配与弱匹配的区别

从漏洞的整体流程来看,其实我们可以把关注点放在shiroAntPath处理引擎上,正是这一步的判断导致了越权

代码位置org.apache.shiro.util.AntPathMatcher#doMatch

  • 弱匹配的情况

函数体如下,我将常量进行了替换,看着方便一点:

protected boolean doMatch(String pattern, String path, boolean fullMatch) {
        if (path.startsWith("/") != pattern.startsWith("/")) {
            return false;
        }

        String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, "/");
        String[] pathDirs = StringUtils.tokenizeToStringArray(path, "/");
//通过"/"符号划分数组
        int pattIdxStart = 0;
        int pattIdxEnd = pattDirs.length - 1;
        int pathIdxStart = 0;
        int pathIdxEnd = pathDirs.length - 1;
    
        // Match all elements up to the first **
        while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
            String patDir = pattDirs[pattIdxStart];
            //patDir = "test"
            if ("**".equals(patDir)) {
                break;
            }
            if (!matchStrings(patDir, pathDirs[pathIdxStart])) {
                return false;
            }
            //matchStrings(patDir, pathDirs[pathIdxStart] = true
            pattIdxStart++; // pattIdxStart=1
            pathIdxStart++;// pathIdxStart=1
        }

        if (pathIdxStart > pathIdxEnd) {
            // 1 > 0 走这个分支
            // Path is exhausted, only match if rest of pattern is * or **'s
            if (pattIdxStart > pattIdxEnd) {
                // 1 > 1 False 不走该分支
                return (pattern.endsWith("/") ?
                        path.endsWith("/") : !path.endsWith("/"));
            }
            if (!fullMatch) {
                // 初始传参fullMatch = true 不走该分支
                return true;
            }
            if (pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") &&
                    path.endsWith("/")) {
                // false
                return true;
            }
            //走下面这个循环
            for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
                if (!pattDirs[i].equals("**")) {
                    // pattDirs里的元素均不满足
                    return false; // <-函数将在这里退出
                }
            }
           return true;
    .....
}
    /*
     * 因此通过该函数没有匹配成功,会交给error或者shiro自带的错误处理器处理(错误为404没有匹配,即找不到该路由)
     * 一般也不会有给错误页面设置权限的需求,所以该请求可放行
    */
  • 强匹配的情况

前面匹配的流程和弱匹配一模一样除了这一步:

            for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
                if (!pattDirs[i].equals("**")) {
                    // pattDirs里的1号元素满足 => 该判断返回!true 跳出循环
                    return false;
                }
            }
           return true;<-函数将在这里退出

这里由于能够匹配到**,所以退出该循环,即匹配成功,这时候Shiro会判断该路由,是否有权限访问,这个路由我们是用auth标记的,因此该用户无权访问,即不放行该请求,此处shiro进行的操作是,将访问路由替换为登陆路由:

Shiro的修复方式

我们比较一下shiro-web-1.5.3.jarshiro-web1.6.0.jar两个jar包看看shiro是怎么修复的,我们使用相同的payload,进行访问发现1.6.0返回了400而非404,

翻看shiro-web-1.6.0.jar,发现WebUtilAntPathMather没有变化,最终在filter文件夹中找到了多了一个InvalidRequestFilter

image-20200926114859836

该类注释为,从源码来看过滤是默认开启的,满足Secure By Default原则:

很显然是为了修复权限绕过而做的了,我们看看源码是如何过滤的:

public class InvalidRequestFilter extends AccessControlFilter {

    private static final List<String> SEMICOLON = Collections.unmodifiableList(Arrays.asList(";", "%3b", "%3B"));

    private static final List<String> BACKSLASH = Collections.unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));
//这里对分号和反斜杠的URL编码进行了标记
    private boolean blockSemicolon = true;

    private boolean blockBackslash = true;

    private boolean blockNonAscii = true;

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        String uri = WebUtils.toHttp(request).getRequestURI();
        return !containsSemicolon(uri)
            && !containsBackslash(uri)
            && !containsNonAsciiCharacters(uri);
    }
    //分别判断是分号,反斜杠,还是不可打印字符的情况,如果检测到了就由下面这个函数  返回
        @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        WebUtils.toHttp(response).sendError(400, "Invalid request");
        return false;
    }
    //判断不可打印的字符采用了类似白名单的模式,个人感觉修复是比较彻底的。
        private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
        int length = uri.length();
        for (int i = 0; i < length; i++) {
            char c = uri.charAt(i);
            if (c < '\u0020' || c > '\u007e') {
                return false;
            }
        }
        return true;
    }

参考链接

Apache Shiro 权限绕过漏洞 CVE-2020-13933

shiro CVE-2020-11989&CVE-2020-13933复现分析

shiro < 1.6.0的认证绕过漏洞分析(CVE-2020-13933)

评论区(暂无评论)

这里空空如也,快来评论吧~

我要评论