0%

WebFlux集成Spring-Security受限静态资源访问授权绕过(CVE-2024-38821)

WebFlux集成Spring-Security受限静态资源访问授权绕过(CVE-2024-38821)

漏洞描述

img.png

影响版本

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>
<!-- Spring Boot Starter for WebFlux -->
<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
img_1.png
访问http://127.0.0.1:8080//index.html 会响应200状态码,并且返回index.html文件内容
img_2.png
还可以对url进行变形 http://127.0.0.1:8080////././../../index.html 也可以访问到该资源

漏洞成因

WebFlux也是遵守servlet规范的,其Filter起点为DefaultWebFilterChainfilter方法
当一次请求第一次进入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方法处理请求
img_3.png
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();
}
}

img_4.png
从上面的代码运行结果可知资源路径是从exchange的属性HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE中获取的,也就是说这个路径在exchange构建的时候已经确定了
此时我们需要回溯exchangeHandlerMapping.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了,所以仍需向前回溯
img_5.png

可以发现 PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE 属性 是从 lookupPath 中提取出来的。
此时查看 lookpath 的值 是一个PathContainer对象,查看其值发现其path值为//index.html
img_6.png
所以从//index.htmlindex.html的转换是发生在 extractPathWithinPattern方法中的
该方法首先会从头开始遍历字符串path的每个元素,如果该元素是Separator 类型则将startIndex 加一,直到遇到一个不为Separator的元素为止,
然后从字符串末尾开始反向遍历遇到Separator类型的元素就将endIndex减一,遇到不是Separator的元素就停止遍历。
然后再从startIndex开始遍历直到endIndex结束,如果再次遇到了Separator类型元素就认为该路径是多层的,此时将multipleAdjacentSeparators设为true
我们的的例子不涉及multipleAdjacent这种情况,那么就截取startIndexendIndex之间的元素,即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来对请求路径进行过滤,并抛出异常,从而阻止请求继续执行。
img.png
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)) { // 未进行url解码时的path
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)) { // 获取的path是url解码后的path
throw new ServerExchangeRejectedException(
"The request was rejected because the URL contained a potentially malicious String \""
+ forbidden + "\"");
}
}
}

rejectedBlocklistedUrls方法最终会调用到valueContains
其中value是客户端请求的path,container是预定义的规则。当valuecontainer的元素时,返回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就是我们使用的//

参考链接

Buy me a coffee.

欢迎关注我的其它发布渠道