springSecurity框架在 WebFlux 下的权限饶过

springSecurity框架在 WebFlux 下的权限饶过

前言

前台的绕过往往是最有价值的,而在 spring 中的前台绕过往往都是路由解析差异造成的,下面分析分析基于 springSecurity 造成的权限绕过,感觉利用解析差异绕过也是非常巧妙的

环境搭建

这里环境搭建很简单,只需要选择漏洞版本

具体的搭建可以参考https://www.dontpanicblog.co.uk/2023/12/09/cve-2023-34034-spring-security-authorization-bypass/

首先是我们的路由配置,在 WebFlux 中使用 springSecurity 来鉴权

@Configuration
@EnableWebFluxSecurity
public class WebFluxSecurityConfig {

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http
                // disable CSRF
                .csrf().disable()

                // add AuthenticationWebFilter and set the handler
                .formLogin()
                .authenticationSuccessHandler(new WebFilterChainServerAuthenticationSuccessHandler())
                .authenticationFailureHandler(((webFilterExchange, exception) -> Mono.error(exception)))


                .and()
                .authorizeExchange()
                .pathMatchers("/admin/**")
                .hasRole("ADMIN")

                .and()
                .authorizeExchange()
                .anyExchange()
                .permitAll();
        return http.build();
    }
}

admin 路由如下

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("admin")
public class AdminResourceController {

    @GetMapping("hello")
    public Mono<String> getPublicResource() {
        return Mono.just("hello admin!");
    }
}

普通的用户路由

@RestController
@RequestMapping("/public")
public class PublicResourceController {

    @GetMapping("/")
    public Mono<String> getPublicResource() {
        return Mono.just("hello!");
    }

}

漏洞复现

我们访问 public 路由,发现不需要鉴权就能访问

然后访问 admin 路由

发现需要登录了

但是如果我们修改配置代码

如下

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.WebFilterChainServerAuthenticationSuccessHandler;
import reactor.core.publisher.Mono;

@Configuration
@EnableWebFluxSecurity
public class WebFluxSecurityConfig {

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http
                // disable CSRF
                .csrf().disable()

                // add AuthenticationWebFilter and set the handler
                .formLogin()
                .authenticationSuccessHandler(new WebFilterChainServerAuthenticationSuccessHandler())
                .authenticationFailureHandler(((webFilterExchange, exception) -> Mono.error(exception)))


                .and()
                .authorizeExchange()
                .pathMatchers("admin/**")
                .hasRole("ADMIN")

                .and()
                .authorizeExchange()
                .anyExchange()
                .permitAll();
        return http.build();
    }
}

重新启动环境再次访问 admin

成功访问到 admin 界面

漏洞分析

上面两种区别其实就是在于对配置中 admin 界面的写法

WebFlux 解析路径流程

首先是我们的 WebFlux 解析路径流程

在寻找对应的处理器的流程中会解析我们的 path


public Mono<HandlerMethod> getHandlerInternal(ServerWebExchange exchange) {
    this.mappingRegistry.acquireReadLock();

    try {
        HandlerMethod handlerMethod;
        try {
            handlerMethod = this.lookupHandlerMethod(exchange);
        } catch (Exception var8) {
            Mono var4 = Mono.error(var8);
            return var4;
        }

        if (handlerMethod != null) {
            handlerMethod = handlerMethod.createWithResolvedBean();
        }

        Mono var3 = Mono.justOrEmpty(handlerMethod);
        return var3;
    } finally {
        this.mappingRegistry.releaseReadLock();
    }
}

调用 lookupHandlerMethod 方法开始寻找处理器


增加我们的路由到 MatchingMapping 中

在这个过程中会不断去获取

getMatchingPatterns 方法

private SortedSet<PathPattern> getMatchingPatterns(ServerWebExchange exchange) {
    PathContainer lookupPath = exchange.getRequest().getPath().pathWithinApplication();
    TreeSet<PathPattern> result = null;
    Iterator var4 = this.patterns.iterator();

    while(var4.hasNext()) {
        PathPattern pattern = (PathPattern)var4.next();
        if (pattern.matches(lookupPath)) {
            result = result != null ? result : new TreeSet();
            result.add(pattern);
        }
    }

    return result;
}

首先从请求中获取路径,然后通过 PathPattern 去匹配


如果匹配成功会返回,而我们的匹配机制来源于 patterns

所以重点就是去寻找我们的 patterns

而 patterns 在于运行我们程序初始化的时候

首先初始化的时候会调用 getMappingForMethod 方法,首先获取我们全部的路由

根据我们的方法名称去寻找对应的路由

protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
    RequestMappingInfo info = this.createRequestMappingInfo(method);
    if (info != null) {
        RequestMappingInfo typeInfo = this.createRequestMappingInfo(handlerType);
        if (typeInfo != null) {
            info = typeInfo.combine(info);
        }

        if (info.getPatternsCondition().isEmptyPathMapping()) {
            info = info.mutate().paths(new String[]{"", "/"}).options(this.config).build();
        }

        Iterator var5 = this.pathPrefixes.entrySet().iterator();

        while(var5.hasNext()) {
            Map.Entry<String, Predicate<Class<?>>> entry = (Map.Entry)var5.next();
            if (((Predicate)entry.getValue()).test(handlerType)) {
                String prefix = (String)entry.getKey();
                if (this.embeddedValueResolver != null) {
                    prefix = this.embeddedValueResolver.resolveStringValue(prefix);
                }

                info = RequestMappingInfo.paths(new String[]{prefix}).options(this.config).build().combine(info);
                break;
            }
        }
    }

    return info;
}

具体的逻辑是在 createRequestMappingInfo 方法内部

public RequestMappingInfo build() {
    PathPatternParser parser = this.options.getPatternParser() != null ? this.options.getPatternParser() : PathPatternParser.defaultInstance;
    RequestedContentTypeResolver contentTypeResolver = this.options.getContentTypeResolver();
    return new RequestMappingInfo(this.mappingName, isEmpty(this.paths) ? null : new PatternsRequestCondition(parse(this.paths, parser)), ObjectUtils.isEmpty(this.methods) ? null : new RequestMethodsRequestCondition(this.methods), ObjectUtils.isEmpty(this.params) ? null : new ParamsRequestCondition(this.params), ObjectUtils.isEmpty(this.headers) ? null : new HeadersRequestCondition(this.headers), ObjectUtils.isEmpty(this.consumes) && !this.hasContentType ? null : new ConsumesRequestCondition(this.consumes, this.headers), ObjectUtils.isEmpty(this.produces) && !this.hasAccept ? null : new ProducesRequestCondition(this.produces, this.headers, contentTypeResolver), this.customCondition, this.options);
}

调用 parse 去处理我们的路由

重点也在于这个 parse 方法

static List<PathPattern> parse(String[] patterns, PathPatternParser parser) {
    if (isEmpty(patterns)) {
        return Collections.emptyList();
    } else {
        List<PathPattern> result = new ArrayList(patterns.length);
        String[] var3 = patterns;
        int var4 = patterns.length;

        for(int var5 = 0; var5 < var4; ++var5) {
            String pattern = var3[var5];
            pattern = parser.initFullPathPattern(pattern);
            result.add(parser.parse(pattern));
        }

        return result;
    }
}

会调用 initFullPathPattern 去处理 pattern

public String initFullPathPattern(String pattern) {
    return StringUtils.hasLength(pattern) && !pattern.startsWith("/") ? "/" + pattern : pattern;
}

会标准化我们的路由加上/


这就是为什么我们代码写的没有加上/也能够成功匹配到对应处理逻辑的原因

Security 的 pathMatchers 匹配流程

在解析路由的时候会调用 matches:74, PathPatternParserServerWebExchangeMatcher (org.springframework.security.web.server.util.matcher)

public Mono<ServerWebExchangeMatcher.MatchResult> matches(ServerWebExchange exchange) {
    ServerHttpRequest request = exchange.getRequest();
    PathContainer path = request.getPath().pathWithinApplication();
    if (this.method != null && !this.method.equals(request.getMethod())) {
        return MatchResult.notMatch().doOnNext((result) -> {
            if (logger.isDebugEnabled()) {
                Log var10000 = logger;
                HttpMethod var10001 = request.getMethod();
                var10000.debug("Request '" + var10001 + " " + path + "' doesn't match '" + this.method + " " + this.pattern.getPatternString() + "'");
            }

        });
    } else {
        boolean match = this.pattern.matches(path);
        if (!match) {
            return MatchResult.notMatch().doOnNext((result) -> {
                if (logger.isDebugEnabled()) {
                    Log var10000 = logger;
                    HttpMethod var10001 = request.getMethod();
                    var10000.debug("Request '" + var10001 + " " + path + "' doesn't match '" + this.method + " " + this.pattern.getPatternString() + "'");
                }

            });
        } else {
            Map<String, String> pathVariables = this.pattern.matchAndExtract(path).getUriVariables();
            Map<String, Object> variables = new HashMap(pathVariables);
            if (logger.isDebugEnabled()) {
                logger.debug("Checking match of request : '" + path + "'; against '" + this.pattern.getPatternString() + "'");
            }

            return MatchResult.match(variables);
        }
    }
}

然后根据请求去获取我们的 path,获取了之后会进行 pattern 的匹配,只有匹配成功后才会将我们的路径放入 variables 中,以供后续权限检测的匹配,所以我们如果需要绕过那么需要实现,访问 admin 能够在 webflux 匹配成功,但是在这里匹配失败

而 pattern 是来源于我们的配置的,首先我们修改一下代码看看匹配成功的看看效果

这是能够匹配成功然后放入我们的变量的

我们分析一下匹配逻辑

public boolean matches(PathContainer pathContainer) {
    if (this.head != null) {
        if (!this.hasLength(pathContainer)) {
            if (!(this.head instanceof WildcardTheRestPathElement) && !(this.head instanceof CaptureTheRestPathElement)) {
                return false;
            }

            pathContainer = EMPTY_PATH;
        }

        MatchingContext matchingContext = new MatchingContext(pathContainer, false);
        return this.head.matches(0, matchingContext);
    } else {
        return !this.hasLength(pathContainer) || this.matchOptionalTrailingSeparator && this.pathContainerIsJustSeparator(pathContainer);
    }
}

获取我们的 head 也就是匹配规则的第一个字符,然后再获取待匹配对象


然后我们再次修改

再次查看匹配结果

这里是匹配失败的

public boolean matches(PathContainer pathContainer) {
    if (this.head != null) {
        if (!this.hasLength(pathContainer)) {
            if (!(this.head instanceof WildcardTheRestPathElement) && !(this.head instanceof CaptureTheRestPathElement)) {
                return false;
            }

            pathContainer = EMPTY_PATH;
        }

        MatchingContext matchingContext = new MatchingContext(pathContainer, false);
        return this.head.matches(0, matchingContext);
    } else {
        return !this.hasLength(pathContainer) || this.matchOptionalTrailingSeparator && this.pathContainerIsJustSeparator(pathContainer);
    }
}

原因在于我们第一个是 admin,而待匹配是/

导致匹配失败,导致我们后续也不会进行安全检测

绕过原理总结

分析了两个路径解析过程后我们再次来总结

绕过的核心就是 webflux 能够对我们输入路径正常解析,但是 security 不能正常解析

而对于 admin 这样的配置路由,在 webflux 会标准化处理,但是对于 security 这样的配置并不会标准化处理,导致了绕过