WebFlux集成Spring-Security受限静态资源访问授权绕过(CVE-2024-38821) 漏洞描述
影响版本 Spring Security以下版本受影响 5.7.0 - 5.7.12 5.8.0 - 5.8.14 6.0.0 - 6.0.12 6.1.0 - 6.1.10 6.2.0 - 6.2.6 6.3.0 - 6.3.3
漏洞测试 创建一个Spring boot + Webflux + Spring Security项目,引入相关依赖
1 2 3 4 5 6 7 8 9 10 11 <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-webflux</artifactId > </dependency > </dependencies >
创建配置类 该类中配置了静态资源index.html
需要登录才能访问,其他url不需要认证即可访问
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 package com.example.demo.config;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.core.userdetails.MapReactiveUserDetailsService;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.web.server.SecurityWebFilterChain;import static org.springframework.security.config.Customizer.withDefaults;@Configuration @EnableWebFluxSecurity public class WebSecurityConfig { @Bean public SecurityWebFilterChain securityWebFilterChain (ServerHttpSecurity http) { return http .authorizeExchange(exchanges -> exchanges .pathMatchers("/index.html" ).authenticated() .anyExchange().permitAll()) .formLogin(withDefaults()) .httpBasic(withDefaults()) .csrf(ServerHttpSecurity.CsrfSpec::disable) .build(); } @Bean public MapReactiveUserDetailsService userDetailsService () { UserDetails admin = User.withUsername("admin" ).password("admin" ).roles("ADMIN" ) .build(); return new MapReactiveUserDetailsService (admin); } }
在resources
目录下创建index.html
文件写入任意内容 此时访问该资源 http://127.0.0.1:8080/index.html
会响应401
状态码提示Unauthorized
访问http://127.0.0.1:8080//index.html
会响应200
状态码,并且返回index.html
文件内容 还可以对url进行变形 http://127.0.0.1:8080////././../../index.html
也可以访问到该资源
漏洞成因 WebFlux也是遵守servlet规范的,其Filter起点为DefaultWebFilterChain
的filter
方法 当一次请求第一次进入filter
的时候currentFilter
的值为WebFilterChainProxy
对象, handler
的值为DispatcherHandler
对象
1 2 3 4 5 public Mono<Void> filter (ServerWebExchange exchange) { return Mono.defer(() -> { return this .currentFilter != null && this .chain != null ? this .invokeFilter(this .currentFilter, this .chain, exchange) : this .handler.handle(exchange); }); }
WebFilterChainProxy 中存储了注册的所有过滤器,当其filter方法被调用时会调用匹配器对请求路径进行匹配, 匹配成功后会将匹配到的过滤器对象赋值给currentFilter,并调用过滤器的filter方法,过滤器执行完后会调用next方法, 继续调用下一个过滤器,直到所有过滤器执行完,最终调用 DispatcherHandler 对象的handle方法处理请求 MatcherSecurityWebFilterChain 我们在签名配置类创建时设置的url访问规则就是在过滤器的处理过程中被验证的,因为我们访问的是//index.html
,该url被认为是不需要认证的。//index.html
不是标准的静态资源访问方法,所以要通过该url访问到资源index.html
肯定还有一个请求路径到服务器资源映射的处理逻辑进行和才能导致绕过。 这个过程发生在请求处理器的处理过程中。 当请求通过了所有过滤器的过滤后,DispatcherHandler 会根据请求url选择合适的请求处理器对请求进行处理 在handleRequestWith
方法中会选取合适的处理器适配器对处理器进行适配然后调用处理器的handler
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 public Mono<Void> handle (ServerWebExchange exchange) { if (this .handlerMappings == null ) { return this .createNotFoundError(); } else { return CorsUtils.isPreFlightRequest(exchange.getRequest()) ? this .handlePreFlight(exchange) : Flux.fromIterable(this .handlerMappings).concatMap((mapping) -> { return mapping.getHandler(exchange); }).next().switchIfEmpty(this .createNotFoundError()).onErrorResume((ex) -> { return this .handleResultMono(exchange, Mono.error(ex)); }).flatMap((handler) -> { return this .handleRequestWith(exchange, handler); }); } }
当访问静态文件的时候使用的处理器为ResourceWebHandler
1 2 3 4 5 6 7 public Mono<Void> handle (ServerWebExchange exchange) { return this .getResource(exchange).switchIfEmpty(Mono.defer(() -> { logger.debug(exchange.getLogPrefix() + "Resource not found" ); return Mono.error(new NoResourceFoundException (this .getResourcePath(exchange))); })) ... });
getResource
方法将尝试获取用户访问的资源
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 protected Mono<Resource> getResource (ServerWebExchange exchange) { String rawPath = this .getResourcePath(exchange); String path = this .processPath(rawPath); if (StringUtils.hasText(path) && !this .isInvalidPath(path)) { if (this .isInvalidEncodedPath(path)) { return Mono.empty(); } else { Assert.state(this .resolverChain != null , "ResourceResolverChain not initialized" ); Assert.state(this .transformerChain != null , "ResourceTransformerChain not initialized" ); return this .resolverChain.resolveResource(exchange, path, this .getLocations()).flatMap((resource) -> { return this .transformerChain.transform(exchange, resource); }); } } else { return Mono.empty(); } }
getResourcePath
方法将尝试通过url获取资源在服务器上的位置
1 2 3 4 5 6 7 8 9 private String getResourcePath (ServerWebExchange exchange) { PathPattern pattern = (PathPattern)exchange.getRequiredAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); if (!pattern.hasPatternSyntax()) { return pattern.getPatternString(); } else { PathContainer pathWithinHandler = (PathContainer)exchange.getRequiredAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); return pathWithinHandler.value(); } }
从上面的代码运行结果可知资源路径是从exchange
的属性HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE
中获取的,也就是说这个路径在exchange
构建的时候已经确定了 此时我们需要回溯exchange
的HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE
属性的构建时机 通过栈回溯,在org.springframework.web.reactive.handler.AbstractUrlHandlerMapping.getHandlerInternal
方法中发现了端倪
1 2 3 4 5 6 7 8 9 10 11 12 public Mono<Object> getHandlerInternal (ServerWebExchange exchange) { PathContainer lookupPath = exchange.getRequest().getPath().pathWithinApplication(); Object handler; try { handler = this .lookupHandler(lookupPath, exchange); } catch (Exception var5) { return Mono.error(var5); } return Mono.justOrEmpty(handler); }
继续跟进到lookupHandler
方法中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 protected Object lookupHandler (PathContainer lookupPath, ServerWebExchange exchange) throws Exception { List<PathPattern> matches = null ; Iterator var4 = this .handlerMap.keySet().iterator(); while (var4.hasNext()) { PathPattern pattern = (PathPattern)var4.next(); if (pattern.matches(lookupPath)) { matches = matches != null ? matches : new ArrayList (); matches.add(pattern); } } if (matches == null ) { return null ; } else { if (matches.size() > 1 ) { matches.sort(PathPattern.SPECIFICITY_COMPARATOR); if (this .logger.isTraceEnabled()) { Log var10000 = this .logger; String var10001 = exchange.getLogPrefix(); var10000.debug(var10001 + "Matching patterns " + matches); } } PathPattern pattern = (PathPattern)matches.get(0 ); PathContainer pathWithinMapping = pattern.extractPathWithinPattern(lookupPath); PathPattern.PathMatchInfo matchInfo = pattern.matchAndExtract(lookupPath); Assert.notNull(matchInfo, "Expected a match" ); Object handler = this .handlerMap.get(pattern); if (handler instanceof String) { String handlerName = (String)handler; handler = this .obtainApplicationContext().getBean(handlerName); } if (this .handlerPredicate != null && !this .handlerPredicate.test(handler, exchange)) { return null ; } else { this .validateHandler(handler, exchange); exchange.getAttributes().put(BEST_MATCHING_HANDLER_ATTRIBUTE, handler); exchange.getAttributes().put(BEST_MATCHING_PATTERN_ATTRIBUTE, pattern); ServerHttpObservationFilter.findObservationContext(exchange).ifPresent((context) -> { context.setPathPattern(pattern.toString()); }); ServerRequestObservationContext.findCurrent(exchange.getAttributes()).ifPresent((context) -> { context.setPathPattern(pattern.toString()); }); exchange.getAttributes().put(PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, pathWithinMapping); exchange.getAttributes().put(URI_TEMPLATE_VARIABLES_ATTRIBUTE, matchInfo.getUriVariables()); return handler; } } }
重点关注下面两行代码
PathContainer pathWithinMapping = pattern.extractPathWithinPattern(lookupPath); exchange.getAttributes().put(PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, pathWithinMapping);
此时 pathWithinMapping 的path值已经被正确提取为index.html
了,所以仍需向前回溯
可以发现 PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE
属性 是从 lookupPath
中提取出来的。 此时查看 lookpath
的值 是一个PathContainer
对象,查看其值发现其path
值为//index.html
所以从//index.html
到index.html
的转换是发生在 extractPathWithinPattern
方法中的 该方法首先会从头开始遍历字符串path的每个元素,如果该元素是Separator
类型则将startIndex
加一,直到遇到一个不为Separator
的元素为止, 然后从字符串末尾开始反向遍历遇到Separator
类型的元素就将endIndex
减一,遇到不是Separator
的元素就停止遍历。 然后再从startIndex
开始遍历直到endIndex
结束,如果再次遇到了Separator
类型元素就认为该路径是多层的,此时将multipleAdjacentSeparators
设为true 我们的的例子不涉及multipleAdjacent
这种情况,那么就截取startIndex
到endIndex
之间的元素,即index.html
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 public PathContainer extractPathWithinPattern (PathContainer path) { List<PathContainer.Element> pathElements = path.elements(); int pathElementsCount = pathElements.size(); int startIndex = 0 ; PathElement elem; for (elem = this .head; elem != null && elem.isLiteral(); ++startIndex) { elem = elem.next; } if (elem == null ) { return PathContainer.parsePath("" ); } else { while (startIndex < pathElementsCount && pathElements.get(startIndex) instanceof PathContainer.Separator) { ++startIndex; } int endIndex; for (endIndex = pathElements.size(); endIndex > 0 && pathElements.get(endIndex - 1 ) instanceof PathContainer.Separator; --endIndex) { } boolean multipleAdjacentSeparators = false ; for (int i = startIndex; i < endIndex - 1 ; ++i) { if (pathElements.get(i) instanceof PathContainer.Separator && pathElements.get(i + 1 ) instanceof PathContainer.Separator) { multipleAdjacentSeparators = true ; break ; } } PathContainer resultPath = null ; if (multipleAdjacentSeparators) { StringBuilder sb = new StringBuilder (); int i = startIndex; while (true ) { PathContainer.Element e; do { if (i >= endIndex) { resultPath = PathContainer.parsePath(sb.toString(), this .pathOptions); return resultPath; } e = (PathContainer.Element)pathElements.get(i++); sb.append(e.value()); } while (!(e instanceof PathContainer.Separator)); while (i < endIndex && pathElements.get(i) instanceof PathContainer.Separator) { ++i; } } } else if (startIndex >= endIndex) { resultPath = PathContainer.parsePath("" ); } else { resultPath = path.subPath(startIndex, endIndex); } return resultPath; } }
上面解释了为什么//index.html
可以绕过认证,下面解释为什么///../
也能绕过认证。../
表示转到上一级目录,所以///../index.html
会被WebFlux
处理成//index.html
,到了路径映射的时候仍然是处理//
的问题。 下面对该漏洞的成因进行总结。 应用配置的需要用户授权的静态资源可通过构造特殊的请求路径进行绕过,原因在于攻击这构造的类似//
与///../
类型的请求因在过滤器中未能与设定的静态资源访问规则匹配, 从而绕过了过滤器验证,而在后续进行请求路径与服务器资源映射的过程中会针对该请求路径进行特殊处理,只提取路径两端/
之间的内容作为服务器上资源的路径 从而导致了攻击者可以通过畸形请求路径访问受限静态资源。
漏洞修复 通过对比spring security
v6.3.3
以及v6.3.4
查看该漏洞的修复措施 diff链接 关键的修复在WebFilterChainProxy
类中 通过新的类StrictServerWebExchangeFirewall
来对请求路径进行过滤,并抛出异常,从而阻止请求继续执行。StrictServerWebExchangeFirewall
是漏洞修复版本新创建的类,内容较多,这里不在贴出代码StrictServerWebExchangeFirewall 在上图的修复方案中调用的是getFirewalledExchange
方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Override public Mono<ServerWebExchange> getFirewalledExchange (ServerWebExchange exchange) { return Mono.fromCallable(() -> { ServerHttpRequest request = exchange.getRequest(); rejectForbiddenHttpMethod(request); rejectedBlocklistedUrls(request); rejectedUntrustedHosts(request); if (!isNormalized(request)) { throw new ServerExchangeRejectedException ( "The request was rejected because the URL was not normalized" ); } exchange.getResponse().beforeCommit(() -> Mono.fromRunnable(() -> { ServerHttpResponse response = exchange.getResponse(); HttpHeaders headers = response.getHeaders(); for (Map.Entry<String, List<String>> header : headers.entrySet()) { String headerName = header.getKey(); List<String> headerValues = header.getValue(); for (String headerValue : headerValues) { validateCrlf(headerName, headerValue); } } })); return new StrictFirewallServerWebExchange (exchange); }); }
对请求路径的检查发生在 rejectedBlocklistedUrls
方法中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private void rejectedBlocklistedUrls (ServerHttpRequest request) { for (String forbidden : this .encodedUrlBlocklist) { if (encodedUrlContains(request, forbidden)) { throw new ServerExchangeRejectedException ( "The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"" ); } } for (String forbidden : this .decodedUrlBlocklist) { if (decodedUrlContains(request, forbidden)) { throw new ServerExchangeRejectedException ( "The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"" ); } } }
rejectedBlocklistedUrls
方法最终会调用到valueContains
其中value
是客户端请求的path,container
是预定义的规则。当value
是container
的元素时,返回true,在上一级方法中将会抛出异常。 从而完成对漏洞的修复。
1 2 3 4 private static boolean valueContains (String value, String contains) { return value != null && value.contains(contains); }
container
的值在StrictServerWebExchangeFirewall
实例化的时候被设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public StrictServerWebExchangeFirewall () { urlBlocklistsAddAll(FORBIDDEN_SEMICOLON); urlBlocklistsAddAll(FORBIDDEN_FORWARDSLASH); urlBlocklistsAddAll(FORBIDDEN_DOUBLE_FORWARDSLASH); urlBlocklistsAddAll(FORBIDDEN_BACKSLASH); urlBlocklistsAddAll(FORBIDDEN_NULL); urlBlocklistsAddAll(FORBIDDEN_LF); urlBlocklistsAddAll(FORBIDDEN_CR); this .encodedUrlBlocklist.add(ENCODED_PERCENT); this .encodedUrlBlocklist.addAll(FORBIDDEN_ENCODED_PERIOD); this .decodedUrlBlocklist.add(PERCENT); this .decodedUrlBlocklist.addAll(FORBIDDEN_LINE_SEPARATOR); this .decodedUrlBlocklist.addAll(FORBIDDEN_PARAGRAPH_SEPARATOR); }
FORBIDDEN_SEMICOLON
等时一些被预定义的常量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 private static final String ENCODED_PERCENT = "%25" ; private static final String PERCENT = "%" ; private static final List<String> FORBIDDEN_ENCODED_PERIOD = Collections .unmodifiableList(Arrays.asList("%2e" , "%2E" )); private static final List<String> FORBIDDEN_SEMICOLON = Collections .unmodifiableList(Arrays.asList(";" , "%3b" , "%3B" )); private static final List<String> FORBIDDEN_FORWARDSLASH = Collections .unmodifiableList(Arrays.asList("%2f" , "%2F" )); private static final List<String> FORBIDDEN_DOUBLE_FORWARDSLASH = Collections .unmodifiableList(Arrays.asList("//" , "%2f%2f" , "%2f%2F" , "%2F%2f" , "%2F%2F" )); private static final List<String> FORBIDDEN_BACKSLASH = Collections .unmodifiableList(Arrays.asList("\\" , "%5c" , "%5C" )); private static final List<String> FORBIDDEN_NULL = Collections.unmodifiableList(Arrays.asList("\0" , "%00" )); private static final List<String> FORBIDDEN_LF = Collections.unmodifiableList(Arrays.asList("\n" , "%0a" , "%0A" )); private static final List<String> FORBIDDEN_CR = Collections.unmodifiableList(Arrays.asList("\r" , "%0d" , "%0D" )); private static final List<String> FORBIDDEN_LINE_SEPARATOR = Collections.unmodifiableList(Arrays.asList("\u2028" )); private static final List<String> FORBIDDEN_PARAGRAPH_SEPARATOR = Collections .unmodifiableList(Arrays.asList("\u2029" ));
FORBIDDEN_DOUBLE_FORWARDSLASH
就是我们使用的//
参考链接