Tomcat 的两个 CVE 漏洞分析

Tomcat 官方在 9 月 19 号公布了两个漏洞 CVE-2017-12616 和 CVE-2017-12615。当启用了虚拟路径,那么虚拟路径中的 jsp 脚本可被攻击者直接获取到源码。当启用了 PUT 方法,可被攻击者上传恶意 jsp 脚本导致代码执行。本文会对漏洞的产生以及官方的修复方法进行分析。

0x00 Tomcat 动态调试

使用动态调试的方式可以很清楚的跟踪漏洞触发的整个流程,下面是对 Tomcat 进行动态调试的配置
Linux:
将 bin/startup.sh 拷贝一份为 debug.startup.sh,再将 debug.startup.sh 中的

1
exec "$PRGDIR"/"$EXECUTABLE" start "$@"

替换成

1
2
3
4
export JPDA_TRANSPORT=dt_socket
export JPDA_ADDRESS=5005
export JPDA_SUSPEND=y
exec "$PRGDIR"/"$EXECUTABLE" jpda start "$@"

Windows:
将 bin/startup.bat 拷贝一份为 debug.startup.bat,再将 debug.startup.bat 中的

1
call "%EXECUTABLE%" start %CMD_LINE_ARGS%

替换成

1
2
3
4
set JPDA_TRANSPORT=dt_socket
set JPDA_ADDRESS=5005
set JPDA_SUSPEND=y
call "%EXECUTABLE%" jpda start %CMD_LINE_ARGS%

去下载相应版本的 Tomcat 源码,然后用 idea 直接把源码导进去就好了,要注意下导入的过程中把 OSGi 的选项给取消了。要是在调试的过程中可能会进入 JDK 的代码中去的话,在设置 JDK 版本的时候要选跟运行着 Tomcat 服务器上一样的。接下来在 idea 添加一个远程调试配置

Debugger mode 为 Attach,Host 为运行 Tomcat 的地址,Port 根据脚本中的 JPDA_ADDRESS 而定。JPDA_SUSPEND 的值默认为 n,只有下了断点才会暂停 Tomcat。设置为 y 的话,启动 Tomcat 时会一直处于等待状态,直到调试器连接过来。运行用来做调试的脚本来启动 Tomcat 后会显示在监听 5005 端口,此时在 idea 上点击调试就会连接上,再在相应的代码处下断点就可以做调试分析了。

0x01 CVE-2017-12616 分析

启用虚拟路径配置:
conf/server.xml 中的 Host 标签下添加

1
2
3
<Context path="">
<Resources className="org.apache.naming.resources.VirtualDirContext" extraResourcePaths="/test=C:/test" />
</Context>

根据 conf/web.xml 中的 servlet-mapping 可以知道除了 url 中匹配到的 .jsp 和 .jspx 会交给 JspServlet 来处理,其它的请求,例如静态资源之类的由 DefaultServlet 来处理。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- The mapping for the default servlet -->
<servlet-mapping>
<servlet-name>default</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<!-- The mappings for the JSP servlet -->
<servlet-mapping>
<servlet-name>jsp</servlet-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>*.jspx</url-pattern>
</servlet-mapping>

所以在做动态调试分析的时候可以选择在对应 Servlet 中的 service 方法,或者是直接根据相应的请求在 doGet 或是 doPut 方法中下断点就可以跟踪整个流程了。
看下 DefaultServlet 的处理流程,从 doGet 方法开始跟的话会发现进入到 BaseDirContext 中的 lookup 方法中去

如果没有设置 aliases 的话就会进入 doLookup 方法中去,如果你设置了虚拟目录的话,这里进入的 doLookup 方法会是 VirtualDirContext 中的 doLookup,否则就是进入 FileDirContext 中的 doLookup 方法。
VirtualDirContext.java 中的 doLookup 方法在 7.0.81 中是有改动的,下面代码是和 7.0.79 (左边)进行的对比

可以看到代码由之前的只是对文件是否存在和是否可读的检测改变为用 validate 方法来进行检测。那么 7.0.1 之前版本在虚拟目录里可以读取 jsp 脚本的源码的漏洞就是由 Windows 下的文件名的特性再加上这里检测不严导致的。利用方法大概如下:

  • test.jsp::$DATA
  • test.jsp.
  • test.jsp%20
  • test.jsP

这些文件名都是不会匹配上 JspServlet 的 url-pattern,所以它们都是由 DefaultServlet 当静态资源请求给返回了

对于 CVE-2017-12615 漏洞有一个利用是在文件名后面添加一个 / 符号,再结合 JDK 中的 File 类会对文件名末尾做一个检测(要是末尾有 / 这样的符号,会自动去掉)来成功创建一个 jsp 脚本。在这里也一样是可以通过这个能读到 jsp 文件的内容,但是在 DefaultServlet 返回之前会对路径末尾有一个检测,要是末尾为 / 或是 \ 符号,直接返回 404。
接下来看下 validate 方法,这个方法是在 7.0.81 版本中由 FileDirContext 中的 file 函数拆分而来的

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
protected File validate(File file, boolean mustExist, String absoluteBase) {
//mustExist 要是被设置为 true,那么文件必须得存在才行
if (!mustExist || file.exists() && file.canRead()) {
if (allowLinking)
return file;
// Check that this file belongs to our root path
String canPath = null;
try {
//获取规范化路径,会去掉文件名末尾的空格和点号,如果系统上存在该文件,返回系统上的文件名(大小写),存在特殊字符返回 null
canPath = file.getCanonicalPath();
} catch (IOException e) {
// Ignore
}
if (canPath == null)
return null;
// Check to see if going outside of the web application root
if (!canPath.startsWith(absoluteBase)) {
return null;
}
// 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 = "/";
//和规范化路径进行对比,不等返回 null
if (!canPath.equals(absPath))
return null;
}
} else {
return null;
}
return file;
}

由此也可以明白为什么只有在虚拟目录下才存在源码泄漏的洞,而正常的 web 路径不会有。在 7.0.81 里的修复方法就是在 VirtualDirContext 中使用 validate 这个方法来做检测。
在这段代码中还有个判断

1
2
if (allowLinking)
return file;

假如 allowLinking 为 true 那么就不会做下面的各种检测了,会直接返回 file,那么自然会造成各种源码泄漏,版本 7,8,9 都可能会有这个问题,而且不局限虚拟路径。allowLinking 默认为 false,需要设置。
Tomcat7:
conf/context.xml

1
<Context allowLinking="true">

Tomcat8/9:

1
2
<Context>
<Resources allowLinking="true" />

在官方的文档中有警告用户不要在 Windows 系统上这样去设置

所以这个也是做安全加固要注意的一个点。

0x02 CVE-2017-12615 分析

启用 PUT 方法配置:
conf/web.xml 中的 DefaultServlet 处添加

1
2
3
4
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>

根据前面说到的,只要在 DefaultServlet 中的 doPut 下断点就好,这里还是会先走一遍 doLookup 方法,不过这里主要是确认文件不存在,最终会在 rebind 方法中创建文件。对比下 7.0.79 和 7.0.81 中 rebind 方法

在 7.0.81 版本中会使用 file 方法来获取文件对象,而不是之前版本那样直接使用 File 类来创建。利用方法和上一个一样。根据给 file 传的参数,可以知道 mustExist 被设置为 false ,所以会进入 validate 方法中的检查。

可以看到在进入 validate 方法之前,File 类已经把文件名末尾的 / 符号给去掉了的,所以这个是对 7.0.81 版本中的一个绕过方法了。此外,长亭的专栏中 Ricter 提出了另外的一种绕过方法,主要是利用到了 getCanonicalPath 方法在存在路径缓存的情况下,不会去掉文件系统上不存在的文件名末尾的空格,再结合 Windows 文件系统的特性做的绕过,具体可以看参考链接。同理 allowLinking 对这个洞一样会有影响,开启 allowLinking 对这个漏洞做个测试

0x03 CVE-2017-12617 分析

这个 CVE 就是前面提的绕过,这里主要看一下官方后来的修复。代码对比版本是 7.0.81 和 7.0.82:
看官方的注释就行,确保文件名末尾不是 /。

再贴一下 validate 方法的修改,和新增的用来防止 getCanonicalPath 方法问题的 isInvalidWindowsFilename。其中官方写的注释也很清楚就不再讲述其中的作用。

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
protected File validate(File file, String name, boolean mustExist, String absoluteBase,
String canonicalBase) {
// If the requested names ends in '/', the Java File API will return a
// matching file if one exists. This isn't what we want as it is not
// consistent with the Servlet spec rules for request mapping.
if (name.endsWith("/") && file.isFile()) {
return null;
}
// If the file/dir must exist but the identified file/dir can't be read
// then signal that the resource was not found
if (mustExist && !file.canRead()) {
return null;
}
// If allow linking is enabled, files are not limited to being located
// under the fileBase so all further checks are disabled.
if (allowLinking) {
return file;
}
// Additional Windows specific checks to handle known problems with
// File.getCanonicalPath()
if (JrePlatform.IS_WINDOWS && isInvalidWindowsFilename(name)) {
return null;
}
// Check that this file is located under the web application root
String canPath = null;
try {
canPath = file.getCanonicalPath();
} catch (IOException e) {
// Ignore
}
if (canPath == null || !canPath.startsWith(canonicalBase)) {
return null;
}
// Ensure that the file is not outside the fileBase. This should not be
// possible for standard requests (the request is normalized early in
// the request processing) but might be possible for some access via the
// Servlet API (RequestDispatcher etc.) therefore these checks are
// retained as an additional safety measure. absoluteBase has been
// normalized so absPath needs to be normalized as well.
String absPath = normalize(file.getAbsolutePath());
if ((absoluteBase.length() > absPath.length())) {
return null;
}
// Remove the fileBase location from the start of the paths since that
// was not part of the requested path and the remaining check only
// applies to the request path
absPath = absPath.substring(absoluteBase.length());
canPath = canPath.substring(canonicalBase.length());
// Case sensitivity check
// The normalized requested path should be an exact match the equivalent
// canonical path. If it is not, possible reasons include:
// - case differences on case insensitive file systems
// - Windows removing a trailing ' ' or '.' from the file name
//
// In all cases, a mis-match here results in the resource not being
// found
//
// absPath is normalized so canPath needs to be normalized as well
// Can't normalize canPath earlier as canonicalBase is not normalized
if (canPath.length() > 0) {
canPath = normalize(canPath);
}
if (!canPath.equals(absPath)) {
return null;
}
return file;
}
private boolean isInvalidWindowsFilename(String name) {
final int len = name.length();
if (len == 0) {
return false;
}
// This consistently ~10 times faster than the equivalent regular
// expression irrespective of input length.
for (int i = 0; i < len; i++) {
char c = name.charAt(i);
if (c == '\"' || c == '<' || c == '>') {
// These characters are disallowed in Windows file names and
// there are known problems for file names with these characters
// when using File#getCanonicalPath().
// Note: There are additional characters that are disallowed in
// Windows file names but these are not known to cause
// problems when using File#getCanonicalPath().
return true;
}
}
// Windows does not allow file names to end in ' ' unless specific low
// level APIs are used to create the files that bypass various checks.
// File names that end in ' ' are known to cause problems when using
// File#getCanonicalPath().
if (name.charAt(len -1) == ' ') {
return true;
}
return false;
}

0x04 参考