作者: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 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1196/