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.
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 > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.3.3</version > <relativePath /> </parent > <properties > <java.version > 17</java.version > </properties > <dependencyManagement > <dependencies > <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 > <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 > <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 > <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
1.txt:666666
正常读取G:/test/test1/test2/static/静态目录下1.txt文件
目录穿越读取G:/test/1.txt文件内容
代码审计 比较spring-framework v6.1.13 和 v6.1.12 diff,发现一条对齐 RouterFunctions 资源处理的 Commit
这条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
IDEA maven下载spring-framework源码,以WebMvc为例进行分析,全局搜索定位到PathResourceLookupFunction类
让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; public PathResourceLookupFunction (String pattern, Resource location) { Assert.hasLength(pattern, "'pattern' must not be empty" ); Assert.notNull(location, "'location' must not be null" ); this .pattern = PathPatternParser.defaultInstance.parse(pattern); this .location = location; } @Override public Optional<Resource> apply (ServerRequest request) { PathContainer pathContainer = request.requestPath().pathWithinApplication(); if (!this .pattern.matches(pathContainer)) { return Optional.empty(); } pathContainer = this .pattern.extractPathWithinPattern(pathContainer); String path = processPath(pathContainer.value()); 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); } } 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 ? "/" : "" ); } private boolean isInvalidPath (String path) { if (path.contains("WEB-INF" ) || path.contains("META-INF" )) { return true ; } 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("../" ); } 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()); } 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("../" ); } @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) { if (!hasLength(path)) { return path; } String normalizedPath; if (path.indexOf('\\' ) != -1 ) { normalizedPath = replace(path, DOUBLE_BACKSLASHES, FOLDER_SEPARATOR); normalizedPath = replace(normalizedPath, WINDOWS_FOLDER_SEPARATOR, FOLDER_SEPARATOR); } else { normalizedPath = path; } String pathToUse = normalizedPath; if (pathToUse.indexOf('.' ) == -1 ) { return pathToUse; } int prefixIndex = pathToUse.indexOf(':' ); String prefix = "" ; if (prefixIndex != -1 ) { 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 ); } String[] pathArray = delimitedListToStringArray(pathToUse, FOLDER_SEPARATOR); 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++; } else { if (tops > 0 ) { 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不会把//当成一个目录
做一下测试,发现解析处理的路径确实不一样
bypass:
利用上面的原理
1 2 //../ 穿越1级目录 ///../../ 穿越2级目录
但由于processPath方法处理不能出现连续的/需要使用\反斜杠进行分割,但由于url不能出现\反斜杠,所以用url编码%5c进行绕过
成功绕过processPath对多个/的限制
最终的payload
__END__