CVE-2020-5405分析

简介

Spring Cloud Config微服务架构的配置中心,为各个不同服务提供了一个中心化的外部配置。不适用本地配置是因为集群间不方便管理,需要一个平台,通常是从github等平台拉取配置。之前在字节工作,他们用的TCC平台,也是一个类似Spring Cloud Config的配置中心

Spring Cloud Config分为服务端和客户端两部分:

  • 服务端,也称为分布式配置中心,它是一个独立的微服务应用,用来连接配置服务器并为客户端提供获取配置信息,加密/解密信息等访问接口
  • 客户端,则是通过指定的配置中心来管理应用资源,以及与业务相关的配置内容,并在启动的时候从配置中心获取和加载配置信息,配置服务器默认采用git来存储配置信息,这样就有助于对环境配置进行版本管理,并且可以通过git客户端工具来方便的管理和访问配置内容

这个漏洞不是RCE,是目录遍历,不算高危

漏洞复现

从github下载spring-cloud-config模块:https://github.com/spring-cloud/spring-cloud-config/archive/v2.1.5.RELEASE.zip

导入IDEA其中的spring-cloud-config-server模块,Maven+SpringBoot项目

修改配置文件src/main/resources/configserver.yml,主要是设置profiles.activenative,设置search-locations为任意文件夹

info:
  component: Config Server
spring:
  application:
    name: configserver
  autoconfigure.exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
  jmx:
    default_domain: cloud.config.server
  profiles:
    active: native
  cloud:
    config:
      server:
        native:
          search-locations:
            - file:///D:/Code
server:
  port: 8888
management:
  context_path: /admin

ConfigServerApplication类启动,访问localhost:8888

手动在D:/Code下创建一个key.txt,尝试读取这个文件

Payload:
http://127.0.0.1:8888/1/1/..(_)..(_)..(_)..(_)..(_)..(_)..(_)..(_)..(_)Code/key.txt

http://127.0.0.1:8888/1/1/..%28_%29..%28_%29..%28_%29..%28_%29..%28_%29..%28_%29..%28_%29..%28_%29Code/key.txt(编码后)

漏洞分析

Config Server通过路径/{name}/{profile}/{label}/{path}对外提供配置文件,POC会通过路由到这个接口,参考org\springframework\cloud\config\server\resource\ResourceController.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);
}
  • name:仓库名称
  • profile:配置文件环境(dev,test,pro)
  • label:分支(1.0.0)
  • **:通配子目录

下断点查看这一步的name和profile等变量分别是什么

Payload:
/1/1/..(_)..(_)..(_)..(_)..(_)..(_)..(_)..(_)..(_)Code/key.txt

  • name:1
  • profile:1
  • label:..()..()..()..()..()..()..()..()..(_)Code
  • **:key.txt

不难看出getFilePath方法作用是解析**为key.txt

private String getFilePath(ServletWebRequest 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.getRequest());
    path = path.substring(path.indexOf(stem) + stem.length());
    return path;
}

最后调用retrieve方法

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

其中的resolveName和resolveLabel方法作用一致,都是将(_)换成/

private String resolveName(String name) {
    if (name != null && name.contains("(_)")) {
        // "(_)" is uncommon in a git repo name, but "/" cannot be matched
        // by Spring MVC
        name = name.replace("(_)", "/");
    }
    return name;
}

所以到达findOne方法这里:

  • label:../../../../../../../../../Code
  • path:key.txt
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;
                        }
                    }
                }
            }
        }
    ......

this.service.getLocations方法应该是调用配置文件并重新拼接,最终得到两个路径,其中第1(2)个是我们的payload

后面的getProfilePaths方法是根据profile构造文件路径。这个主要是参照日常习惯,比如开发配置文件会命名为confg-dev.yaml这样

其实程序做了非法路径的判断:isInvalidPath,不过它校验的path,而我们的path是key.txt,不是违法的路径

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

最后通过this.resourceLoader.getResource(location).createRelative(local);加载了本地文件,这个没必要跟踪,已经确认了过程

漏洞修复

补丁:https://github.com/spring-cloud/spring-cloud-config/commit/651f458919c40ef9a5e93e7d76bf98575910fad0

补丁对location进行了验证,而不只是简单地对path做验证

实际中也确实应该对最后一步的输入进行判断,而不只是对过程进行判断

private boolean isInvalidLocation(String location) {
    boolean isInvalid = location.contains("..");

    if (isInvalid && logger.isWarnEnabled()) {
        logger.warn("Location contains \"..\"");
    }
    return isInvalid;
}