前言

SHIRO-721的Padding Oracle攻击太麻烦了,知道个原理就算了,接下来学习一下Shiro认证绕过的漏洞。


环境搭建

要求版本:Shiro<1.7.1。

首先要配置一个包含通配符的,要求认证的路径:

map.put("/admin/*", "authc");

再在springboot中配置一个符合该路径的资源:

@RequestMapping("/admin/{username}")
public String admin(@PathVariable String username) {
    System.out.println("username: " + URLEncoder.encode(username));
    return "admin";
}

这种情况下如果访问/admin/admin之类的路径,就会被重定向至index界面。

访问/admin/不会进行认证,但是会返回404。

空格绕过

访问/admin/%20可以绕过认证直接访问到spring中的admin路径,进行路径匹配的点主要有两个,不那么关键的点在于PathMatchingFilter类的pathsMatch函数:

protected boolean pathsMatch(String path, ServletRequest request) {
    String requestURI = getPathWithinApplication(request);
    if (requestURI != null && !DEFAULT_PATH_SEPARATOR.equals(requestURI)
        && requestURI.endsWith(DEFAULT_PATH_SEPARATOR)) {
        requestURI = requestURI.substring(0, requestURI.length() - 1);
    }
    if (path != null && !DEFAULT_PATH_SEPARATOR.equals(path)
        && path.endsWith(DEFAULT_PATH_SEPARATOR)) {
        path = path.substring(0, path.length() - 1);
    }
    log.trace("Attempting to match pattern '{}' with current requestURI '{}'...", path, Encode.forHtml(requestURI));
    return pathsMatch(path, requestURI);
}

第一段lf去掉请求路径末尾的/,第二段if去掉配置的认证路径末尾的/,完成后:

最后调用了另一个pathsMatch函数:

protected PatternMatcher pathMatcher = new AntPathMatcher();

protected boolean pathsMatch(String pattern, String path) {
    return pathMatcher.matches(pattern, path);
}

即AntPathMatcher的matches函数,然后来到其doMatch函数中,这个函数很长:

String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, this.pathSeparator);
String[] pathDirs = StringUtils.tokenizeToStringArray(path, this.pathSeparator);

首先是将pattern和path按照/切割成字符串数组,但是我们观察切割后的结果:

可以看到path中的空格被舍弃了,只剩下了前面admin的部分,我们去看看StringUtils.tokenizeToStringArray函数究竟做了什么:

public static String[] tokenizeToStringArray(String str, String delimiters) {
    return tokenizeToStringArray(str, delimiters, true, true);
}

public static String[] tokenizeToStringArray(String str, String delimiters, boolean trimTokens, boolean ignoreEmptyTokens) {
    if (str == null) {
        return null;
    } else {
        StringTokenizer st = new StringTokenizer(str, delimiters);
        ArrayList tokens = new ArrayList();

        while(true) {
            String token;
            do {
                if (!st.hasMoreTokens()) {
                    return toStringArray(tokens);
                }

                token = st.nextToken();
                if (trimTokens) {
                    token = token.trim();
                }
            } while(ignoreEmptyTokens && token.length() <= 0);

            tokens.add(token);
        }
    }
}

StringTokenizer类是Java内部的字符串分割类,关键点在循环中,当trimTokens为true时会调用trim函数,而trimTokens来自参数,是一个写死的true,所以当循环到我们输入的空格时,trim函数就会调用,将字符串头尾空白字符删除。而因为该段字符串只有一个空格,最后就什么都不剩下了。

回到doMatch函数中:

// Match all elements up to the first **
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
    String patDir = pattDirs[pattIdxStart];
    if ("**".equals(patDir)) {
        break;
    }
    if (!matchStrings(patDir, pathDirs[pathIdxStart])) {
        return false;
    }
    pattIdxStart++;
    pathIdxStart++;
}

因为pattDirs有admin和通配符两个部分,而pathDirs只剩下前半的admin,所以第一部分匹配成功后就会因为pathDirs已经遍历完而退出这段循环,看下一段代码:

if (pathIdxStart > pathIdxEnd) {
    // Path is exhausted, only match if rest of pattern is * or **'s
    if (pattIdxStart > pattIdxEnd) {
        return (pattern.endsWith(this.pathSeparator) ?
                path.endsWith(this.pathSeparator) : !path.endsWith(this.pathSeparator));
    }
    if (!fullMatch) {
        return true;
    }
    if (pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") &&
        path.endsWith(this.pathSeparator)) {
        return true;
    }
    for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
        if (!pattDirs[i].equals("**")) {
            return false;
        }
    }
    return true;
}

当path被匹配完时会进入这段代码,因为pattern没有匹配完不会进入第一个判断,因为fullMatch来自参数而且为true不会进入第二个判断,因为path不是以/结尾不会进入第三个判断,最后因为pattern是*不是**会返回false。

上面说的不那么关键的点只是用来确认filter链要不要继续执行,比较关键的点在于之前获取要执行的filter链,即PathMatchingFilterChainResolver类的getChain函数:

public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
    FilterChainManager filterChainManager = getFilterChainManager();
    if (!filterChainManager.hasChains()) {
        return null;
    }

    String requestURI = getPathWithinApplication(request);

    ...

    return null;
}

首先通过getPathWithinApplication函数获取URI:

public static String getPathWithinApplication(HttpServletRequest request) {
    return normalize(removeSemicolon(getServletPath(request) + getPathInfo(request)));
}

这里会通过normalize函数将路径标准化,将一些相关字符去掉,比如//和/.之类的。

不过调试可以发现,.的去除跟normalize函数无关,而是getPathInfo的返回值为空字符串,同时还发现当访问/admin/123123时getPathInfo的返回值同样为空字符串,123123的部分会在getServletPath函数返回,不懂这个属性有什么用的。

往上看到tomcat的部分可以看到:

// Normalization
if (normalize(req.decodedURI())) {
    // Character decoding
    convertURI(decodedURI, request);
    // Check that the URI is still normalized
    // Note: checkNormalize is deprecated because the test is no
    //       longer required in Tomcat 10 onwards and has been
    //       removed
    if (!checkNormalize(req.decodedURI())) {
        response.sendError(400, "Invalid URI");
    }
}

URI会经过normalize的标准化,后面的.就没了,变成了/admin/。

回到getChain函数,然后:

// in spring web, the requestURI "/resource/menus" ---- "resource/menus/" bose can access the resource
// but the pathPattern match "/resource/menus" can not match "resource/menus/"
// user can use requestURI + "/" to simply bypassed chain filter, to bypassed shiro protect
if(requestURI != null && !DEFAULT_PATH_SEPARATOR.equals(requestURI)
   && requestURI.endsWith(DEFAULT_PATH_SEPARATOR)) {
    requestURI = requestURI.substring(0, requestURI.length() - 1);
}

去掉了末尾的/,变成了/admin,再到:

//the 'chain names' in this implementation are actually path patterns defined by the user.  We just use them
//as the chain name for the FilterChainManager's requirements
for (String pathPattern : filterChainManager.getChainNames()) {
    if (pathPattern != null && !DEFAULT_PATH_SEPARATOR.equals(pathPattern)
        && pathPattern.endsWith(DEFAULT_PATH_SEPARATOR)) {
        pathPattern = pathPattern.substring(0, pathPattern.length() - 1);
    }

    // If the path does match, then pass on to the subclass implementation for specific checks:
    if (pathMatches(pathPattern, requestURI)) {
        if (log.isTraceEnabled()) {
            log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + Encode.forHtml(requestURI) + "].  " +
                      "Utilizing corresponding filter chain...");
        }
        return filterChainManager.proxy(originalChain, pathPattern);
    }
}

遍历配置的pattern,通过pathMatches函数确定哪些filter是要调用的:

protected boolean pathMatches(String pattern, String path) {
    PatternMatcher pathMatcher = getPathMatcher();
    return pathMatcher.matches(pattern, path);
}

pathMatcher同样是个AntPathMatcher,后面的匹配流程就跟前面所说的一致了:

public PathMatchingFilterChainResolver() {
    this.pathMatcher = new AntPathMatcher();
    this.filterChainManager = new DefaultFilterChainManager();
}

public PathMatchingFilterChainResolver(FilterConfig filterConfig) {
    this.pathMatcher = new AntPathMatcher();
    this.filterChainManager = new DefaultFilterChainManager(filterConfig);
}

public PatternMatcher getPathMatcher() {
    return pathMatcher;
}

最后到了spring中,这个空格就会作为参数被赋值到参数中:

/./绕过

听说首先要开启spring中的全路径,不然会影响spring的路径匹配,具体开启方法可以看这里

不过我这里好像不用开启也可以正常访问:

.同样被作为了参数:

通过调试可以发现,我环境下的alwaysUseFullPath在初始化时就赋值为了true,找一下setAlwaysUseFullPath函数的调用路径:

// AbstractHandlerMapping
public void setAlwaysUseFullPath(boolean alwaysUseFullPath) {
    this.urlPathHelper.setAlwaysUseFullPath(alwaysUseFullPath);
    if (this.corsConfigurationSource instanceof UrlBasedCorsConfigurationSource) {
        ((UrlBasedCorsConfigurationSource) this.corsConfigurationSource).setAlwaysUseFullPath(alwaysUseFullPath);
    }
}

// UrlBasedCorsConfigurationSource
@Deprecated
public void setAlwaysUseFullPath(boolean alwaysUseFullPath) {
    initUrlPathHelper();
    this.urlPathHelper.setAlwaysUseFullPath(alwaysUseFullPath);
}

可以看到不管怎么样最后调用的都是urlPathHelper的setAlwaysUseFullPath函数:

public void setAlwaysUseFullPath(boolean alwaysUseFullPath) {
    checkReadOnly();
    this.alwaysUseFullPath = alwaysUseFullPath;
}

最后放入alwaysUseFullPath属性中,在后续访问中会通过调用getLookupPathForRequest函数来访问该属性:

public String getLookupPathForRequest(HttpServletRequest request) {
    String pathWithinApp = getPathWithinApplication(request);
    // Always use full path within current servlet context?
    if (this.alwaysUseFullPath || skipServletPathDetermination(request)) {
        return pathWithinApp;
    }
    // Else, use path within current servlet mapping if applicable
    String rest = getPathWithinServletMapping(request, pathWithinApp);
    if (StringUtils.hasLength(rest)) {
        return rest;
    }
    else {
        return pathWithinApp;
    }
}

正如注释所说,Always use full path,也就不用特地做设置了,此时会将整个path即/admin/.返回:

调用栈如下:

往上回到resolveAndCacheLookupPath函数:

public String resolveAndCacheLookupPath(HttpServletRequest request) {
    String lookupPath = getLookupPathForRequest(request);
    request.setAttribute(PATH_ATTRIBUTE, lookupPath);
    return lookupPath;
}

将获取到的路径即/admin/.放入request中,再往上回到getHandlerInternal函数:

protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
    String lookupPath = initLookupPath(request);
    this.mappingRegistry.acquireReadLock();
    try {
        HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
        return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
    }
    finally {
        this.mappingRegistry.releaseReadLock();
    }
}

根据访问路径查取处理函数,进入lookupHandlerMethod函数,一步步来到getMatchingCondition函数:

PatternsRequestCondition patterns = null;
if (this.patternsCondition != null) {
    patterns = this.patternsCondition.getMatchingCondition(request);
    if (patterns == null) {
        return null;
    }
}

这里存在patternsCondition条件,进入getMatchingCondition函数进行匹配:

public PatternsRequestCondition getMatchingCondition(HttpServletRequest request) {
    String lookupPath = UrlPathHelper.getResolvedLookupPath(request);
    List<String> matches = getMatchingPatterns(lookupPath);
    return !matches.isEmpty() ? new PatternsRequestCondition(new LinkedHashSet<>(matches), this) : null;
}

// UrlPathHelper
public static String getResolvedLookupPath(ServletRequest request) {
    String lookupPath = (String) request.getAttribute(PATH_ATTRIBUTE);
    Assert.notNull(lookupPath, "Expected lookupPath in request attribute \"" + PATH_ATTRIBUTE + "\".");
    return lookupPath;
}

从request中取出了之前存入的/admin/.,接下来就是正则匹配了,此时的正则规则为:

?s表示单行匹配,让.能匹配换行符,简单来说就是基本什么字符都行。

spring的部分看完了,shiro的部分就不用看了,问题就出在tomcat通过normalize函数标准化了URI。

修复方式

空格

调用StringUtils.tokenizeToStringArray函数时:

String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, this.pathSeparator, false, true);
String[] pathDirs = StringUtils.tokenizeToStringArray(path, this.pathSeparator, false, true);

将trimTokens设置为了false,不会再调用trim去除空白字符了。

/./

两个点都做了修复,在PathMatchingFilterChainResolver类的getChain函数中:

final String requestURI = getPathWithinApplication(request);
final String requestURINoTrailingSlash = removeTrailingSlash(requestURI);

//the 'chain names' in this implementation are actually path patterns defined by the user.  We just use them
//as the chain name for the FilterChainManager's requirements
for (String pathPattern : filterChainManager.getChainNames()) {
    ...
}

少了去掉末尾/的环节,这样到了doMatch函数中就会因为path被匹配完,且末尾存在/而返回true。不仅如此,如果没去掉末尾/的路径匹配失败,还会用去掉末尾/的路径再试一次:

if (pathMatches(pathPattern, requestURI)) {
    ...
    return filterChainManager.proxy(originalChain, pathPattern);
} else {

    // in spring web, the requestURI "/resource/menus" ---- "resource/menus/" bose can access the resource
    // but the pathPattern match "/resource/menus" can not match "resource/menus/"
    // user can use requestURI + "/" to simply bypassed chain filter, to bypassed shiro protect

    pathPattern = removeTrailingSlash(pathPattern);

    if (pathMatches(pathPattern, requestURINoTrailingSlash)) {
        ...
        return filterChainManager.proxy(originalChain, requestURINoTrailingSlash);
    }
}

而在PathMatchingFilter的pathsMatch函数中也一样:

String requestURI = getPathWithinApplication(request);

log.trace("Attempting to match pattern '{}' with current requestURI '{}'...", path, Encode.forHtml(requestURI));
boolean match = pathsMatch(path, requestURI);

也是删掉了去掉末尾/的部分代码,同样也有去掉末尾/之后再试一次:

if (!match) {
    if (requestURI != null && !DEFAULT_PATH_SEPARATOR.equals(requestURI)
        && requestURI.endsWith(DEFAULT_PATH_SEPARATOR)) {
        requestURI = requestURI.substring(0, requestURI.length() - 1);
    }
    if (path != null && !DEFAULT_PATH_SEPARATOR.equals(path)
        && path.endsWith(DEFAULT_PATH_SEPARATOR)) {
        path = path.substring(0, path.length() - 1);
    }
    log.trace("Attempting to match pattern '{}' with current requestURI '{}'...", path, Encode.forHtml(requestURI));
    match = pathsMatch(path, requestURI);
}

参考文章

https://www.freebuf.com/vuls/262927.html


Web Shiro

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

CVE-2020-1957 Shiro认证绕过
反序列化中的JRMP末路(?)