spring-framework目录遍历漏洞(cve-2024-38816)代码审计

refer:

https://securityonline.info/cve-2024-38816-spring-framework-path-traversal-vulnerability-threatens-millions/

影响版本:

spring-framework < 5.3.40

spring-framework < 6.0.24

spring-framework < 6.1.13

Any application using the affected versions of Spring Framework (5.3.0 to 5.3.39, 6.0.0 to 6.0.23, and 6.1.0 to 6.1.12) and serving static resources through the vulnerable components is at risk.

该漏洞在于 Spring Framework 如何处理通过功能性 Web 框架 WebMvc.fn 或 WebFlux.fn 提供的静态资源。通过构建恶意 HTTP 请求,攻击者可以绕过安全措施并从服务器的文件系统中检索任意文件,包括配置文件、源代码和用户数据。

The vulnerability lies in how Spring Framework handles static resources served through the functional web frameworks WebMvc.fn or WebFlux.fn. By crafting malicious HTTP requests, attackers can bypass security measures and retrieve arbitrary files from the server’s file system, including configuration files, source code, and user data.

修改比对 6.1.13 & 6.1.12

漏洞复现

环境搭建:

JDK 21 > 17(spring boot 3.x只支持 > JDK17)

spring boot 3.3.3

spring-framework-bom 6.1.12

测试WebMvc.fn:spring-boot-starter-web, spring-boot-starter-undertow

测试WebFlux.fn:spring-boot-starterwebflux,spring-boot-starter-undertow

spring-boot-starter-web和spring-boot-starter-webflux最 好不要出现在⼀个项目,webmvc会干扰webflux。 不用再指定版本,springboot做过版本管理。tomcat和 jetty不支持这个漏洞,所以使用嵌入的undertow服务器,使用springboot内置的tomcat会报错400.

image-20240924091139925

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>your-project-name</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>

<!-- 设置 Spring Boot 父项目 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<!-- 定义项目的属性 -->
<properties>
<java.version>17</java.version> <!-- 或根据需求选择 Java 版本 -->
<!-- 通常不需要单独指定 Spring Framework 版本,因为 Spring Boot 管理了 -->
<!-- <spring-framework.version>6.1.12</spring-framework.version> -->
</properties>

<!-- 管理依赖版本 -->
<dependencyManagement>
<dependencies>
<!-- 通常不需要手动引入 Spring Framework BOM -->
<!-- Spring Boot 父项目已经管理了 Spring Framework 的版本 -->
<!-- 如果确实需要,确保版本兼容 -->
<!--
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-framework-bom</artifactId>
<version>${spring-framework.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
-->
<!-- 引入 Spring Boot BOM 以确保兼容性 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.3.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<!-- 项目依赖 -->
<dependencies>
<!-- Spring Boot Starter Web,排除默认的 Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>

<!-- Spring Boot Starter Undertow 代替默认的 Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

<!-- 其他必要的依赖,例如测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<!-- 插件配置 -->
<build>
<plugins>
<!-- Spring Boot Maven 插件 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.3.3</version>
</plugin>
</plugins>
</build>

</project>

创建WebMvc.fn或WebFlux.fn静态资源映射配置类。代码没区别,导包不⼀样,导⼊webmvc或webflux包下的 RouterFunction,ServerResponse,RouterFunctions。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;


@Configuration
public class WebConfig {
@Bean
public RouterFunction<ServerResponse> route() {
return RouterFunctions
.resources("/static/**", new
FileSystemResource("G:/test/test1/test2/static/"));
}
}

复现过程:

目录结构如下:

1.txt:555555

image-20240924091522898

1.txt:666666

image-20240924091609168

正常读取G:/test/test1/test2/static/静态目录下1.txt文件

image-20240924091727296

目录穿越读取G:/test/1.txt文件内容

image-20240924091755049

代码审计

比较spring-framework v6.1.13 和 v6.1.12 diff,发现一条对齐 RouterFunctions 资源处理的 Commit

image-20240924094356795

这条commit修改了

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java

spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java

分别对应WebFlux.fn和WebMvc.fn

image-20240924094656924

IDEA maven下载spring-framework源码,以WebMvc为例进行分析,全局搜索定位到PathResourceLookupFunction类

image-20240924095833820

让AI给这段代码加上注释

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceUtils;
import org.springframework.util.Assert;
import org.springframework.util.PathPattern;
import org.springframework.util.PathPatternParser;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import reactor.core.publisher.Mono;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.function.Function;

/**
* 一个函数接口实现,用于根据请求路径匹配和查找相应的资源。
*/
class PathResourceLookupFunction implements Function<ServerRequest, Optional<Resource>> {

/**
* 用于匹配请求路径的模式。
*/
private final PathPattern pattern;

/**
* 资源的基础位置。
*/
private final Resource location;

/**
* 构造函数,初始化路径模式和资源位置。
*
* @param pattern 路径模式字符串,用于匹配请求路径。
* @param location 资源的基础位置,不能为 null。
*/
public PathResourceLookupFunction(String pattern, Resource location) {
// 确保路径模式字符串不为空
Assert.hasLength(pattern, "'pattern' must not be empty");
// 确保资源位置不为 null
Assert.notNull(location, "'location' must not be null");
// 解析路径模式字符串为 PathPattern 对象
this.pattern = PathPatternParser.defaultInstance.parse(pattern);
this.location = location;
}

/**
* 根据服务器请求查找匹配的资源。
*
* @param request 服务器请求对象。
* @return 一个包含匹配资源的 Optional 对象,如果没有匹配则返回 Optional.empty()。
*/
@Override
public Optional<Resource> apply(ServerRequest request) {
// 获取请求路径的 PathContainer
PathContainer pathContainer = request.requestPath().pathWithinApplication();
// 如果路径不匹配模式,返回空
if (!this.pattern.matches(pathContainer)) {
return Optional.empty();
}

// 提取匹配的路径部分
pathContainer = this.pattern.extractPathWithinPattern(pathContainer);
// 处理路径,去除不必要的字符
String path = processPath(pathContainer.value());
// 如果路径包含百分号,进行 URI 解码
if (path.contains("%")) {
path = StringUtils.uriDecode(path, StandardCharsets.UTF_8);
}
// 如果路径为空或无效,返回空
if (!StringUtils.hasLength(path) || isInvalidPath(path)) {
return Optional.empty();
}

try {
// 在基础位置创建相对路径的资源
Resource resource = this.location.createRelative(path);
// 如果资源可读且位于基础位置下,返回该资源
if (resource.isReadable() && isResourceUnderLocation(resource)) {
return Optional.of(resource);
} else {
return Optional.empty();
}
} catch (IOException ex) {
// 将检查型异常转换为非检查型异常并抛出
throw new UncheckedIOException(ex);
}
}

/**
* 处理路径,去除前导和不必要的斜杠,并确保路径有效。
*
* @param path 输入的路径字符串。
* @return 处理后的路径字符串。
*/
private String processPath(String path) {
boolean slash = false;
for (int i = 0; i < path.length(); i++) {
if (path.charAt(i) == '/') {
slash = true;
} else if (path.charAt(i) > ' ' && path.charAt(i) != 127) {
if (i == 0 || (i == 1 && slash)) {
return path;
}
// 去除前导无效字符
path = slash ? "/" + path.substring(i) : path.substring(i);
return path;
}
}
// 如果路径仅包含斜杠,返回斜杠或空字符串
return (slash ? "/" : "");
}

/**
* 检查路径是否无效,防止访问受限制的目录或进行路径穿越攻击。
*
* @param path 需要检查的路径字符串。
* @return 如果路径无效则返回 true,否则返回 false。
*/
private boolean isInvalidPath(String path) {
// 禁止访问 WEB-INF 和 META-INF 目录
if (path.contains("WEB-INF") || path.contains("META-INF")) {
return true;
}
// 检查是否包含协议或 URL 前缀,防止绝对路径访问
if (path.contains(":/")) {
String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path);
if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) {
return true;
}
}
// 检查路径是否包含路径穿越序列
return path.contains("..") && StringUtils.cleanPath(path).contains("../");
}

/**
* 检查资源是否位于基础位置下,防止路径穿越。
*
* @param resource 需要检查的资源。
* @return 如果资源位于基础位置下则返回 true,否则返回 false。
* @throws IOException 如果读取资源路径时发生 IO 异常。
*/
private boolean isResourceUnderLocation(Resource resource) throws IOException {
// 确保资源类型与基础位置一致
if (resource.getClass() != this.location.getClass()) {
return false;
}

String resourcePath;
String locationPath;

// 根据资源类型获取路径
if (resource instanceof UrlResource) {
resourcePath = resource.getURL().toExternalForm();
locationPath = StringUtils.cleanPath(this.location.getURL().toString());
}
else if (resource instanceof ClassPathResource classPathResource) {
resourcePath = classPathResource.getPath();
locationPath = StringUtils.cleanPath(((ClassPathResource) this.location).getPath());
}
else {
resourcePath = resource.getURL().getPath();
locationPath = StringUtils.cleanPath(this.location.getURL().getPath());
}

// 如果资源路径与基础位置路径相同,返回 true
if (locationPath.equals(resourcePath)) {
return true;
}
// 确保基础位置路径以斜杠结尾
locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/");
// 检查资源路径是否以基础位置路径开头
if (!resourcePath.startsWith(locationPath)) {
return false;
}
// 确保资源路径不包含路径穿越序列
return !resourcePath.contains("%") ||
!StringUtils.uriDecode(resourcePath, StandardCharsets.UTF_8).contains("../");
}

/**
* 返回对象的字符串表示,包含路径模式和资源位置。
*
* @return 对象的字符串表示。
*/
@Override
public String toString() {
return this.pattern + " -> " + this.location;
}

}

在代码中有StringUtils.cleanPath(path).contains(“../“)去检查目录穿越,继续跟踪cleanPath方法,这个方法用来处理路径中的反斜杠、斜杠、不必要的当前目录符号(.)、上级目录符号(..)等

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public static String cleanPath(String path) {
// 如果路径为空或长度为0,直接返回原路径
if (!hasLength(path)) {
return path;
}

String normalizedPath;
// 优化处理:如果路径中包含反斜杠(\),则进行替换
if (path.indexOf('\\') != -1) {
// 将双反斜杠替换为单一的文件夹分隔符(通常为 /)
normalizedPath = replace(path, DOUBLE_BACKSLASHES, FOLDER_SEPARATOR);
// 将 Windows 特有的文件夹分隔符(\)替换为统一的文件夹分隔符(/)
normalizedPath = replace(normalizedPath, WINDOWS_FOLDER_SEPARATOR, FOLDER_SEPARATOR);
} else {
// 如果没有反斜杠,直接使用原路径
normalizedPath = path;
}
String pathToUse = normalizedPath;

// 快捷方式:如果路径中不包含点(.),则无需进一步处理,直接返回
if (pathToUse.indexOf('.') == -1) {
return pathToUse;
}

// 处理路径前缀(例如 "file:"),以避免将其作为路径的一部分进行解析
int prefixIndex = pathToUse.indexOf(':');
String prefix = "";
if (prefixIndex != -1) {
// 提取前缀(例如 "file:")
prefix = pathToUse.substring(0, prefixIndex + 1);
// 如果前缀中包含文件夹分隔符,说明前缀不合法,忽略前缀
if (prefix.contains(FOLDER_SEPARATOR)) {
prefix = "";
} else {
// 移除前缀部分,保留后面的路径进行处理
pathToUse = pathToUse.substring(prefixIndex + 1);
}
}
// 如果路径以文件夹分隔符开头,保留分隔符并移除路径开头的分隔符
if (pathToUse.startsWith(FOLDER_SEPARATOR)) {
prefix = prefix + FOLDER_SEPARATOR;
pathToUse = pathToUse.substring(1);
}

// 将路径按文件夹分隔符拆分为数组
// private static final String FOLDER_SEPARATOR = "/";
String[] pathArray = delimitedListToStringArray(pathToUse, FOLDER_SEPARATOR);
// 使用双端队列(Deque)来存储处理后的路径元素
Deque<String> pathElements = new ArrayDeque<>(pathArray.length);
int tops = 0; // 记录 ".." 的数量

// 从路径数组的最后一个元素开始逆序遍历
for (int i = pathArray.length - 1; i >= 0; i--) {
String element = pathArray[i];
if (CURRENT_PATH.equals(element)) {
// 当前目录(.)无需处理,直接跳过
} else if (TOP_PATH.equals(element)) {
// 遇到上一级目录(..),增加 tops 计数
tops++;
} else {
if (tops > 0) {
// 如果有待消除的上一级目录,减少 tops 计数,忽略当前元素
tops--;
} else {
// 正常的路径元素,添加到队列的前面
pathElements.addFirst(element);
}
}
}

// 如果处理后的路径元素数量与原数组相同,说明没有需要清理的部分,直接返回规范化后的路径
if (pathArray.length == pathElements.size()) {
return normalizedPath;
}
// 保留剩余的上一级目录(..)
for (int i = 0; i < tops; i++) {
pathElements.addFirst(TOP_PATH);
}
// 如果队列中只有一个空元素,并且前缀不以文件夹分隔符结尾,添加当前目录(.)
if (pathElements.size() == 1 && pathElements.getLast().isEmpty() && !prefix.endsWith(FOLDER_SEPARATOR)) {
pathElements.addFirst(CURRENT_PATH);
}

// 将队列中的路径元素重新连接为字符串,使用文件夹分隔符作为分隔符
final String joined = collectionToDelimitedString(pathElements, FOLDER_SEPARATOR);
// 如果有前缀,则将前缀与连接后的路径组合,否则直接返回连接后的路径
return prefix.isEmpty() ? joined : prefix + joined;
}

该漏洞原理与CVE-2018-1271 Spring MVC的穿越漏洞原理一致,由于String[] pathArray = delimitedListToStringArray(pathToUse, FOLDER_SEPARATOR);的方法会将//当成⼀个目录,而FileSystem不会把//当成一个目录

image-20240924101504489

做一下测试,发现解析处理的路径确实不一样

image-20240924103139013

image-20240924103155246

bypass:

利用上面的原理

1
2
//../ 穿越1级目录
///../../ 穿越2级目录

但由于processPath方法处理不能出现连续的/需要使用\反斜杠进行分割,但由于url不能出现\反斜杠,所以用url编码%5c进行绕过

image-20240924104820926

成功绕过processPath对多个/的限制

image-20240924104943838

最终的payload

image-20240924091755049

__END__