作者:Spoock
博客:https://blog.spoock.com/2020/05/09/cve-2020-1957/

本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:paper@seebug.org

环境搭建

根据 Spring Boot 整合 Shiro ,两种方式全总结!。我配置的权限如下所示:

@Bean
ShiroFilterFactoryBean shiroFilterFactoryBean() {
    ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
    bean.setSecurityManager(securityManager());
    bean.setLoginUrl("/login");
    bean.setSuccessUrl("/index");
    bean.setUnauthorizedUrl("/unauthorizedurl");
    Map<String, String> map = new LinkedHashMap<>();
    map.put("/admin/**", "authc");
    bean.setFilterChainDefinitionMap(map);
    return bean;
}

........
@RequestMapping("/admin/index")
public String test() {
    return "This is admin index page";
}

会对admin所有的页面都会进行权限校验。测试结果如下:

访问index

访问admin/index

漏洞分析

绕过演示

在shiro的1.5.1及其之前的版本都可以完美地绕过权限检验,如下所示;

绕过原理分析

我们需要分析我们请求的URL在整个项目的传入传递过程。在使用了shiro的项目中,是我们请求的URL(URL1),进过shiro权限检验(URL2), 最后到springboot项目找到路由来处理(URL3)

漏洞的出现就在URL1,URL2和URL3 有可能不是同一个URL,这就导致我们能绕过shiro的校验,直接访问后端需要首选的URL。本例中的漏洞就是因为这个原因产生的。

http://localhost:8080/xxxx/..;/admin/index 为例,一步步分析整个流程中的请求过程。

protected String getPathWithinApplication(ServletRequest request) {
    return WebUtils.getPathWithinApplication(WebUtils.toHttp(request));
}

public static String getPathWithinApplication(HttpServletRequest request) {
        String contextPath = getContextPath(request);
        String requestUri = getRequestUri(request);
        if (StringUtils.startsWithIgnoreCase(requestUri, contextPath)) {
            // Normal case: URI contains context path.
            String path = requestUri.substring(contextPath.length());
            return (StringUtils.hasText(path) ? path : "/");
        } else {
            // Special case: rather unusual.
            return requestUri;
        }
    }


public static String getRequestUri(HttpServletRequest request) {
        String uri = (String) request.getAttribute(INCLUDE_REQUEST_URI_ATTRIBUTE);
        if (uri == null) {
            uri = request.getRequestURI();
        }
        return normalize(decodeAndCleanUriString(request, uri));
    }

此时的URL还是我们传入的原始URL: /xxxx/..;/admin/index

接着,程序会进入到decodeAndCleanUriString(), 得到:

private static String decodeAndCleanUriString(HttpServletRequest request, String uri) {
        uri = decodeRequestString(request, uri);
        int semicolonIndex = uri.indexOf(';');
        return (semicolonIndex != -1 ? uri.substring(0, semicolonIndex) : uri);
    }

decodeAndCleanUriString 以 ;截断后面的请求,所以此时返回的就是 /xxxx/...然后程序调用normalize() 对decodeAndCleanUriString()处理得到的路径进行标准化处理. 标准话的处理包括:

  • 替换反斜线
  • 替换 ///
  • 替换 /.//
  • 替换 /..//

都是一些很常见的标准化方法.

private static String normalize(String path, boolean replaceBackSlash) {

        if (path == null)
            return null;

        // Create a place for the normalized path
        String normalized = path;

        if (replaceBackSlash && normalized.indexOf('\\') >= 0)
            normalized = normalized.replace('\\', '/');

        if (normalized.equals("/."))
            return "/";

        // Add a leading "/" if necessary
        if (!normalized.startsWith("/"))
            normalized = "/" + normalized;

        // Resolve occurrences of "//" in the normalized path
        while (true) {
            int index = normalized.indexOf("//");
            if (index < 0)
                break;
            normalized = normalized.substring(0, index) +
                    normalized.substring(index + 1);
        }

        // Resolve occurrences of "/./" in the normalized path
        while (true) {
            int index = normalized.indexOf("/./");
            if (index < 0)
                break;
            normalized = normalized.substring(0, index) +
                    normalized.substring(index + 2);
        }

        // Resolve occurrences of "/../" in the normalized path
        while (true) {
            int index = normalized.indexOf("/../");
            if (index < 0)
                break;
            if (index == 0)
                return (null);  // Trying to go outside our context
            int index2 = normalized.lastIndexOf('/', index - 1);
            normalized = normalized.substring(0, index2) +
                    normalized.substring(index + 3);
        }

        // Return the normalized path that we have completed
        return (normalized);

    }

经过getPathWithinApplication()函数的处理,最终shiro 需要校验的URL 就是 /xxxx/... 最终会进入到 org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver 中的 getChain()方法会URL校验. 关键的校验方法如下:

由于 /xxxx/.. 并不会匹配到 /admin/**, 所以shiro权限校验就会通过.

最终我们的原始请求 /xxxx/..;/admin/index 就会进入到 springboot中. springboot对于每一个进入的request请求也会有自己的处理方式,找到自己所对应的mapping. 具体的匹配方式是在:org.springframework.web.util.UrlPathHelper 中的 getPathWithinServletMapping()

getPathWithinServletMapping() 在一般情况下返回的就是 servletPath, 所以本例中返回的就是 /admin/index.最终到了/admin/index 对应的requestMapping, 如此就成功地访问了后台请求.

最后,我们来数理一下整个请求过程:

  1. 客户端请求URL: /xxxx/..;/admin/index
  2. shrio 内部处理得到校验URL为 /xxxx/..,校验通过
  3. springboot 处理 /xxxx/..;/admin/index , 最终请求 /admin/index, 成功访问了后台请求.

commmit分析

对应与修复的commit是: Add tests for WebUtils

其中关键的修复代码如下;

对比与1.5.1的版本获取request.getRequestURI(), 在此基础上,对其进行标准化,分析, 由于 getRequestURI是直接返回请求URL,导致了可以被绕过.

在1.5.2的版本中是由contextPath()+ servletPath()+ pathinfo()组合而成. 以 /xxxx/..;/admin/index为例, ,修正后的URL是:

经过修改后.shiro处理的URL就是 /admin/index, 发现需要进行权限校验,因此不就会放行.

其他

偶然发现 这样也可以绕过shiro的权限校验, 但是这种情况和上面的情况是不一样的. 上面的情况是shiro校验的URL和最终进入到springboot中需要处理的URL是不一样的.

增加一个路由

@RequestMapping("/admin")
    public String test2() {
        return "This is the default admi controller";
    }

在这种情况下,可以访问到/admin这样的路由. 但仅此而已, 并不访问访问更多/admin下方更多的路由. 接下来分析这种原因.按照前面的一贯分析, 我们同样可以知道 在 org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver() 中的getChain()是可以通过检验的. 因为 /admin.index 不属于/admin/**

在springboot中需要通过request找到对应的handler进行处理. springboot是在 org.springframework.web.servlet.handler.AbstractHandlerMethodMapping 这个函数中,通过 lookupPath找到对应的handler.

通过上述的截图也可以看出, springboot获取的也是 /admin.index 这个URL. 但是可以成功地找到handler来处理.所以本质上 这个 /admin.index路由可以绕过 shiro 是springboot内部通过URL找到handler的一个机制.与shiro并没有关系. 我们进行一个简单的测试:

@RequestMapping("/index")
public String index() {
    return "This is homepage";
}

完全没有使用shiro, 大家也可以测试下.所以这个问题其实在shiro 1.5.2 上面也同样是可以的.

上面的测试只是一种最简单的情况, 只有shiro配置了一个全局的权限校验, 就有可能存在绕过的问题, 如果程序进一步在URL上面配置了权限校验,即使绕过了ShiroFilterChainDefinition, 但是还是无法绕过注解上面的防御.如下所示:

@Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chain = new DefaultShiroFilterChainDefinition();
        //哪些请求可以匿名访问
        chain.addPathDefinition("/user/login", "anon");
        chain.addPathDefinition("/page/401", "anon");
        chain.addPathDefinition("/page/403", "anon");
        chain.addPathDefinition("/t5/hello", "anon");
        chain.addPathDefinition("/t5/guest", "anon");

        //除了以上的请求外,其它请求都需要登录
        chain.addPathDefinition("/**", "authc");
        return chain;
    }


@RestController
@RequestMapping("/t5")
public class Test5Controller {
    @RequiresUser
    @GetMapping("/user")
    public String user() {
        return "@RequiresUser";
    }
}

总结

讲到这里,差不多有关这个漏洞的所有问题都说完了.其实本文章还涉及到一些其他的知识.比如:

  1. requesturi 和 servlet的区别
  2. springmvc的请求处理流程

这些都可以写一篇文章来进行说明了.整体来说,这个漏的利用方式还是很简单的,我测试了目前大部分使用shiro的应用基本上都存在绕过的问题, 但是这个漏洞能够找成多大的危害呢? 就目前看来危害还是有限的,因为即使绕过了shiro的权限校验,但是一般情况下这些接口/请求都需要对应用户的权限,所以绕过了shiro登录到后台系统只是以一种没有用户身份的方式登录到后台系统, 后台校验此时获取当前用户信息,发现为空.此时整体系统就会出错,或者重新跳转到登录页面,重新登录.这里就不作说明了


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1196/