【java安全】Spring权限绕过

简单分析总结一下Spring权限绕过的一些方法

spring权限绕过

前言

经过长达20年的发展,spring框架已经进入6.0时代,目前国内在使用的版本主要还是4.0和5.0版本

Spring Boot是一个Spring的组件集合,它帮我们预组装了Springl的一系列组件,通过Spring Boot我们可以在极
短的时间内搭建一套基于Spring框架的WEB系统。

Spring Boot与spring框架的版本对应关系如下:

Spring Boot版本 Spring版本
3.x 6.x
2.x 5.x
1.x 4.x

环境

不同版本的Spring框架在路由处理上存在一定差异,本文相关源码采用Spring Boot2.2.0.RELEASE版本
对应Spring Framework的版本为5.2.0.RELEASE
,WEB容器采用默认的tomcat

@RestController
public class AdminController {

    @RequestMapping("/admin")
    public String admin(){
        return "You are admin";
    }
}

前置知识

在进行分析之前,需要了解一下spring中如何对路由进行处理

从网上获取到一张原理图

image

首先所有请求会进入Dispatcher Servlet,由其进行分发,其大致处理逻辑是根据请求信息遍历HandlerMappinng,找到对应请求的HandlerMapping交由其进行处理

换句话说:DispatcherServlet会调用对应HandlerMapping来解析URL,完成Controller的匹配,接着执行业务操作,最后将数据交给ViewResolver处理视图映射

image

Spring MVC 框架中有多种不同的 HandlerMapping 实现,每种实现都有不同的作用和使用场景

  • BeanNameUrlHandlerMapping 是最简单的HandlerMapping实现,它将 URL 请求的路径直接映射到 Bean 的名称上。
  • SimpleUrlHandlerMapping 将 URL 请求的路径与相应的处理程序进行映射。
  • ControllerClassNameHandlerMapping 将 URL 请求的路径映射到 Controller 类名上。
  • DefaultAnnotationHandlerMapping 将 URL 请求的路径映射到标注了 @RequestMapping 注解的方法上
  • RequestMappingHandlerMapping 用户通过RequestMapping注解实现的接口
  • ....

分析

我们的测试代码是通过RequestMapping注解编写的一个接口(大部分接口都是这样编写),因此经过Mapping的遍历后会匹配到RequestMappingHandlerMapping进行处理

image

深入分析getHandler​方法,看看是如何匹配成功的

可以看到先是调用了getHandlerInternal​方法,该方法调用完成后即匹配到了org.example.controller.AdminController#admin()

 @Nullable
    public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
        Object handler = this.getHandlerInternal(request);
        if (handler == null) {
            handler = this.getDefaultHandler();
        }

        if (handler == null) {
            return null;
        } else {
            if (handler instanceof String) {
                String handlerName = (String)handler;
                handler = this.obtainApplicationContext().getBean(handlerName);
            }

            HandlerExecutionChain executionChain = this.getHandlerExecutionChain(handler, request);
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("Mapped to " + handler);
            } else if (this.logger.isDebugEnabled() && !request.getDispatcherType().equals(DispatcherType.ASYNC)) {
                this.logger.debug("Mapped to " + executionChain.getHandler());
            }

            if (this.hasCorsConfigurationSource(handler)) {
                CorsConfiguration config = this.corsConfigurationSource != null ? this.corsConfigurationSource.getCorsConfiguration(request) : null;
                CorsConfiguration handlerConfig = this.getCorsConfiguration(handler, request);
                config = config != null ? config.combine(handlerConfig) : handlerConfig;
                executionChain = this.getCorsHandlerExecutionChain(request, executionChain, config);
            }

            return executionChain;
        }
    }

深入查看getHandlerInternal​方法,可以发现其先获取了lookupPath​,接着通过lookupPath​和request​去查找对应的处理方法

protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
        String lookupPath = this.getUrlPathHelper().getLookupPathForRequest(request);
        request.setAttribute(LOOKUP_PATH, lookupPath);
        this.mappingRegistry.acquireReadLock();

        HandlerMethod var4;
        try {
            HandlerMethod handlerMethod = this.lookupHandlerMethod(lookupPath, request);
            var4 = handlerMethod != null ? handlerMethod.createWithResolvedBean() : null;
        } finally {
            this.mappingRegistry.releaseReadLock();
        }

        return var4;
    }

深入分析getLookupPathForRequest​方法,可以看到一个alwaysUseFullPath​,顾名思义就是“全路径查找”,该配置在Spring Boot版本小于等于2.3.0 RELEASE默认为false,根据这个开关决定调用getPathWithinApplication​或者getPathWithinServletMapping​,rest为空时也会调用getPathWithinApplication​方法,因此后面会重点分析该方法(因为大部分程序的配置都会走到这个函数里面处理路径)

public String getLookupPathForRequest(HttpServletRequest request) {
        if (this.alwaysUseFullPath) {
            return this.getPathWithinApplication(request);
        } else {
            String rest = this.getPathWithinServletMapping(request);
            return !"".equals(rest) ? rest : this.getPathWithinApplication(request);
        }
    }

查询得到这两个方法的区别:

  1. getPathWithinApplication​:

    • 这个方法是 Spring Framework 中的方法,用于获取请求 URL 相对于应用程序的路径。
    • 它返回的是请求 URL 相对于应用程序根路径的路径部分,不包括应用程序的上下文路径。
    • 例如,如果你的应用程序部署在 /myapp​ 上下文路径下,而请求的 URL 是 /myapp/users/123​,那么 getPathWithinApplication​ 返回的将是 /users/123​。
  2. getPathWithinServletMapping​:

    • 这个方法通常是在 Servlet API 或类似的框架中提供的,用于获取请求 URL 相对于 Servlet 映射的路径。
    • 它返回的是请求 URL 相对于 Servlet 映射路径的路径部分,不包括上下文路径。
    • 如果你有一个 Servlet 映射到 /users/*​,而请求的 URL 是 /myapp/users/123​,那么 getPathWithinServletMapping​ 返回的将是 /123​。

总的来说,getPathWithinApplication是在 Spring Framework 中用于获取相对于应用程序根路径的路径,而 getPathWithinServletMapping则是在 Servlet API 中用于获取相对于 Servlet 映射路径的路径。

因为测试环境Spring Boot版本小于等于2.3.0,所以继续跟进getPathWithinServletMapping​方法,其首先调用了getPathWithinApplication

  public String getPathWithinServletMapping(HttpServletRequest request) {
        String pathWithinApp = this.getPathWithinApplication(request);
        String servletPath = this.getServletPath(request);
        String sanitizedPathWithinApp = this.getSanitizedPath(pathWithinApp);
        String path;
        if (servletPath.contains(sanitizedPathWithinApp)) {
            path = this.getRemainingPath(sanitizedPathWithinApp, servletPath, false);
        } else {
            path = this.getRemainingPath(pathWithinApp, servletPath, false);
        }

        if (path != null) {
            return path;
        } else {
            String pathInfo = request.getPathInfo();
            if (pathInfo != null) {
                return pathInfo;
            } else {
                if (!this.urlDecode) {
                    path = this.getRemainingPath(this.decodeInternal(request, pathWithinApp), servletPath, false);
                    if (path != null) {
                        return pathWithinApp;
                    }
                }

                return servletPath;
            }
        }
    }

继续跟进getPathWithinApplication​,跟前面分析一致,用于获取相对于应用程序根路径的路径,但是有一些处理,contextPath​是获取应用上下文路径

    public String getPathWithinApplication(HttpServletRequest request) {
        String contextPath = this.getContextPath(request);
        String requestUri = this.getRequestUri(request);
        String path = this.getRemainingPath(requestUri, contextPath, true);
        if (path != null) {
            return StringUtils.hasText(path) ? path : "/";
        } else {
            return requestUri;
        }
    }

继续深入getRequestUri​方法,发现其对路径进行了处理decodeAndCleanUriString

public String getRequestUri(HttpServletRequest request) {
        String uri = (String)request.getAttribute("javax.servlet.include.request_uri");
        if (uri == null) {
            uri = request.getRequestURI();
        }

        return this.decodeAndCleanUriString(request, uri);
    }

深入decodeAndCleanUriString​方法,发现三个处理的方法

    private String decodeAndCleanUriString(HttpServletRequest request, String uri) {
        uri = this.removeSemicolonContent(uri);
        uri = this.decodeRequestString(request, uri);
        uri = this.getSanitizedPath(uri);
        return uri;
    }

removeSemicolonContent:用于移除请求 URI 中的分号内容,如URI:/;cxzdsadw/admin,处理后即为//admin

decodeRequestString:URL解码

getSanitizedPath:循环移除"//",如//admin处理后即为/admin

再继续分析getPathWithinServletMapping​方法剩余的代码,绝大部分情况下获取的三个路径是相等的,也就是直接返回,当存在目录穿越时,通常会返回servletPath

其中getServletPath​方法最终会调用web容器的getServletPath​,在不同的容器中该方法的返回值有差异

以tomcat为例,当我们输入/api/..;/admin时,该方法将返回常规化后的url,即/admin, getPathWithinServletMapping正常情况下会返回空字符串

image

分析完路径的处理过程,接着分析如何通过路径查找到对应的处理过程org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#lookupHandlerMethod

image

发现只有springfox.documentation.spring.web.PropertySourcedRequestMappingHandlerMapping​重写了该方法,并且该方法内是通过类似hashmap形式去查找匹配的,url匹配很严格必须一一对应,因此在绕过类似swagger这种基于配置生成的接口是很难绕过的

image

绝大部分场景都是走的默认的lookupHandlerMethod​方法,该方法在没有匹配到处理方法时会使用最佳匹配的算法,寻找最佳匹配的处理器,比如"/admin/"会匹配上"/admin"

protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
        List<AbstractHandlerMethodMapping<T>.Match> matches = new ArrayList();
        List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
        if (directPathMatches != null) {
            this.addMatchingMappings(directPathMatches, matches, request);
        }

        if (matches.isEmpty()) {
            this.addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request);
        }

        if (!matches.isEmpty()) {
            Comparator<AbstractHandlerMethodMapping<T>.Match> comparator = new MatchComparator(this.getMappingComparator(request));
            matches.sort(comparator);
            AbstractHandlerMethodMapping<T>.Match bestMatch = (Match)matches.get(0);
            if (matches.size() > 1) {
                if (this.logger.isTraceEnabled()) {
                    this.logger.trace(matches.size() + " matching mappings: " + matches);
                }

                if (CorsUtils.isPreFlightRequest(request)) {
                    return PREFLIGHT_AMBIGUOUS_MATCH;
                }

                AbstractHandlerMethodMapping<T>.Match secondBestMatch = (Match)matches.get(1);
                if (comparator.compare(bestMatch, secondBestMatch) == 0) {
                    Method m1 = bestMatch.handlerMethod.getMethod();
                    Method m2 = secondBestMatch.handlerMethod.getMethod();
                    String uri = request.getRequestURI();
                    throw new IllegalStateException("Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}");
                }
            }

            request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.handlerMethod);
            this.handleMatch(bestMatch.mapping, lookupPath, request);
            return bestMatch.handlerMethod;
        } else {
            return this.handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request);
        }
    }

漏洞利用与修复

在漏洞利用上,主要使用的方法是url路径格式化的绕过、智能匹配算法导致的绕过

首先添加一个Interceptor​进行拦截

image

正常访问,被拦截

image

使用路径格式化绕过案例1:"//admin"

image

使用路径格式化绕过案例2: "/;xxx/admin"

image

使用路径格式化绕过案例3: "/%61%64%6d%69%6e"

image

使用路径格式化绕过案例4:"/aa/../admin"image

漏洞原理与修复

在tomcat容器下,通过getRequestURI/getRequestURL获取到的url是请求的原始url,即未经过任何处理和转换的url, 通过前面的源码分析,Spring框架会自动清除url中的特殊字符并进行url解码,因此在Spring框架下如使用相关的漏洞 方法做鉴权,则很容易出现权限绕过漏洞。

因此,推荐使用较安全的方法一getServletPath获取请求的url,该方法获取到的url是经过tomcat'常规化处理后的,即做了和Spring框架类似的清理与转换,使得其对url的解释结果一致,从而避免因处理不一致导致的权限绕过问题。 例如,访问/;//api/../admin路径时,getRequestURI和getServletPath获取到的url分别如下图所示:

image-20240525165616328

不严格的黑名单导致的权限绕过

漏洞源码

image

再使用前面的方法发现无法绕过,因为这里用了getServletPath​方法获取路径

image

借助Spring路径智能匹配机制去绕过

image

尾部增加.xxx绕过(Spring Boot版本小于等于1.5.22.RELEASE时useSuffixPatternMatch默认为true)

image

原理及修复

由于spring框架存在最佳路由匹配算法,导致访问/admin/时也能访问到路由定义为/admin的接口,从安全的角度,在任何时候都不建议使用黑名单防护,当然在万不得已的时候建议能够更加严格,如将equals方法改为contains方法

总结

Spring作为一款主流的、优秀的JAVA开发框架,其功能非常强大,在开发人员使用不当时虽然可能存在较多的安全漏洞,但这严格意义上并不属于框架本身的安全问题,属于开发人员的逻辑不严谨从而导致Spring框架与WEB容器之间产生解析差异导致的逻辑漏洞。站在安全的角度,建议在任何时候都不要优先通过黑名单的形式进行访问策略控制。

参考

black_memory师傅的视频分享