介绍

漏洞信息

由于 Tomact AJP 协议的设计缺陷,攻击者可以通过漏洞读取 webapp 目录下的任意文件,或者通过文件包含 getshell

编号:CNVD-2020-10487 / CVE-2020-1938
外号:Ghostcat

TomcatAJP 是默认开启的,一般来说只要发现目标机器开放了 8009 端口并且 Tomcat 的版本处于受影响版本中,就会有漏洞。

AJP的介绍

AJP 协议中,请求参数是以二进制格式传输的。在处理 AJP 请求时,Tomcat 会将请求参数解析为一个名值对的列表,并将其转换为 HTTP 请求的属性,以便应用服务器更好处理请求。

AJP 协议设计的主要目的就是快,不过现在主要用于反向代理,就是其它的 web 服务器(例如 Apache 作为客户端到 Tomcat 的中间件,客户端连接 Apache 时采用 http 协议,Apache 再连接后台的 Tomcat 时采用 AJP 协议(也可以不用)。你可以会好奇为什么客户端不直接使用 AJP 协议,因为浏览器不支持,浏览器采用的是更通用的 http 协议。

影响版本

1
2
3
4
Apache Tomcat 9.x < 9.0.31
Apache Tomcat 8.x < 8.5.51
Apache Tomcat 7.x < 7.0.100
Apache Tomcat 6.x

poc

这里网上有很多工具,这里就不再重新分析 AJP 协议来造轮子了。后面都是基于工具 https://github.com/00theway/Ghostcat-CNVD-2020-10487 来验证漏洞的。

用法:

1
2
3
4
文件读取
python ajpShooter.py http://127.0.0.1:8080/<目标项目> 8009 /WEB-INF/web.xml read
文件包含(getshell的前提是能够上传文件)
python ajpShooter.py http://127.0.0.1:8080/<目标项目> 8009 /a.txt eval

漏洞分析

漏洞触发点

这里以 Tomcat 8.5.42 的源代码来分析调试。

由于我们刚开始对这个漏洞的触发点一无所知,只知道 poc ,所以可以先通过 githubdiff 功能看一下修复了漏洞后的版本做了哪些修改。

通过代码对比功能我们可以看到 tomcat8.5.50 版本升级到 8.5.51 版本一共更改了 156 个文件。

通过 ajp 关键字可以发现 ajp 相关的源代码修改集中在 org.apache.coyote.ajp 包里。

进一步分析改动的源代码,我们可以发现 org.apache.coyote.ajp.AjpProcessor 中的 prepareRequest() 方法改动的比较大。因此过会调试 Tomcat 源码的时候可以在这个方法打上断点来分析分析。

发现当我们发送 poc 时,确实可以在这个方法断住。

此外,由于这里的漏洞和文件读取相关,因此肯定会用到 java.io.File ,所以我们可以给 File 的构造方法的开头都打上断点,看什么时候进入的参数是 poc 读取的文件。不过这里要先运行起来后再打断点,不然 File 的断点会比较多。

发现也成功断住了,这样就很容易看出来漏洞的触发点在哪里了。

发现漏洞的关键是请求交给到了 org.apache.catalina.servlets.DefaultServlet 来处理。

而在这个类在处理请求的时候会在 getRelativePath() 中读取 request 中的 RequestDispatcher.INCLUDE_SERVLET_PATH 属性并返回,交给 resources.getResource() 方法读取对应的文件内容,并在最后返回到 response 中。

webapps目录下任意文件读取

这里我们就会好奇一点,这里 TomcatDefaultServlet 是做什么的,并且请求为什么会交给这个 Servlet 进行处理。

根据查找的资料可以知道:
DefaultServlet 为默认的 Servlet ,当客户端请求不能匹配其他所有 Servlet 时,将由这个 Servlet 处理。DefaultServlet 主要用于处理静态资源,如 HTML 、图片、CSSJS 文件等,而且为了提升服务器性能,Tomcat 会对访问文件进行缓存。

但是我们知道,正常情况下 DefaultServlet 是不应该能够读取 WEB-INFMETA-INF 目录下的内容的,但是这里的 poc 是可以的,这里是怎么做到的?我们先来分析正常情况下,Tomcat 是怎么防止上面两个目录下的内容被读取的。

正常访问时,在 StandardContextValve.invoke() 中会判断 request 中的 requestPath 属性(其值等于去掉了项目名后的 URI )是否等于或以 /META-INF 或者 /WEB-INF 开头,如果是就终止请求,返回 404

然后我们再观察漏洞情况下这里的参数。发现即使我们目标读取的文件是 /WEB-INF/web.xml ,但是这里的 requestPath 却是 /index.txt

接着我们来抓包看一下上面的 poc 到底发了什么恶意的数据给 Tomcat

可以发现 index.txtAJP 协议数据包中 URI字段的内容,而我们实际读取的文件内容在 javax.servlet.include.request_path 字段中。这时我们需要分析 Tomcat 是怎么处理 AJP 协议的了。为什么 requestPath 的值可以和实际 Tomcat 返回的文件路径不一致。这里就到了这个漏洞的关键所在。

漏洞的关键

这个漏洞的关键就是在 AjpProcessor 中的 prepareRequest() 方法存在漏洞,可以让攻击者通过控制 AJP 协议报文的请求内容,来为 request 对象设置任意的 attribute

我们来分析一下这个方法。
首先要知道 AJP 的请求数据包存在 AjpMessagebuf 字段中,可以对比一下这个字段和上面 wireshark 抓包的内容是一致的。

prepareRequest() 这个方法主要是在处理 AJP 数据包(也就是处理 ApiMessagebuf 字段),将其信息存到 request 中。

在这个方法的末尾会根据数据包中的内容对 requestattribute 赋值,而且没有任何过滤。这样我们就可以控制 requestattributes 属性。

而正好在 DefaultServletgetRelativePath() 中,如果 request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI) 的值不为 null ,就会返回 servletPath = (String) request.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH) 的值,然后读取这个值对应的文件。

这样两个 gadget 合在一起就造成了任意文件读取漏洞。这里由于在读取文件的时候会判断文件路径是否合法,从而无法通过 ../ 来读取 webapps 目录外的内容。

介绍完了文件读取漏洞,我们再看看是怎么完成文件包含漏洞的。

文件包含命令执行

通过抓包,我们发现 poc 唯一的变化就是 URI ,将原来的 index.txt 改为了 index.jsp

我们再来跟一下 Tomcat 的流程,由于这次是命令执行,因此我们把断点打到 Runtime.exec() 方法的开头。

发现这次不再是利用 DefaultServlet 来读文件了,而是利用 JspServlet 来编译攻击者指定的文件,即使文件的后缀名不为 jsp

这里包含的逻辑是如果 jspUrinull ,那就从 request.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH) 中获取值来编译。如果后面的值还为 null ,就通过用户请求的 URI 来获取要编译的 jsp 文件。所以用户正常访问时,代码应该走到 else 中,这里我们通过让 request.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH) 不为 null ,从而没有走到 else 语句中。

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
@Override
public void service (HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {

// jspFile may be configured as an init-param for this servlet instance
String jspUri = jspFile;

if (jspUri == null) {
/*
* Check to see if the requested JSP has been the target of a
* RequestDispatcher.include()
*/
jspUri = (String) request.getAttribute(
RequestDispatcher.INCLUDE_SERVLET_PATH);
if (jspUri != null) {
/*
* Requested JSP has been target of
* RequestDispatcher.include(). Its path is assembled from the
* relevant javax.servlet.include.* request attributes
*/
String pathInfo = (String) request.getAttribute(
RequestDispatcher.INCLUDE_PATH_INFO);
if (pathInfo != null) {
jspUri += pathInfo;
}
} else {
/*
* Requested JSP has not been the target of a
* RequestDispatcher.include(). Reconstruct its path from the
* request's getServletPath() and getPathInfo()
*/
jspUri = request.getServletPath();
String pathInfo = request.getPathInfo();
if (pathInfo != null) {
jspUri += pathInfo;
}
}
}

if (log.isDebugEnabled()) {
log.debug("JspEngine --> " + jspUri);
log.debug("\t ServletPath: " + request.getServletPath());
log.debug("\t PathInfo: " + request.getPathInfo());
log.debug("\t RealPath: " + context.getRealPath(jspUri));
log.debug("\t RequestURI: " + request.getRequestURI());
log.debug("\t QueryString: " + request.getQueryString());
}

try {
boolean precompile = preCompile(request);
serviceJspFile(request, response, jspUri, precompile);
} catch (RuntimeException e) {
throw e;
} catch (ServletException e) {
throw e;
} catch (IOException e) {
throw e;
} catch (Throwable e) {
ExceptionUtils.handleThrowable(e);
throw new ServletException(e);
}

}

Tomcat如何判断某次请求交给哪个Servlet处理

poc 是怎么控制请求是走到 JspServlet 中还是 DefaultServlet 中呢?这就要看 Tomcat 是怎么分发请求的。这里的核心逻辑在 org.apache.catalina.mapper.Mapper#internalMap() 中。

这个方法的主要作用就是在判断某次请求对应的 hostcontextmapper ,并将其封装到 request 中,以便后续通过 pipeline 的机制依次调用。

这个方法首先根据 requesthost 字段来判断这次请求对应的 HostTomcat 往往只有一个 Host ,因此这里的逻辑就不细说了。

然后是根据 requesturi 来判断这次请求对应的 Context ,也就是访问的是 webapps 下面部署的哪个项目,因为 Tomcat 是可以同时部署多个项目的。这里判断的逻辑是 uri 的开头。

然后专门将判断某次请求对应的 Mapper (也就是这次请求交给哪个 Servlet 处理)封装到 internalMapWrapper() 方法中来调用,因为这一块稍微复杂点。

具体逻辑如下:

contextVersion.exactWrapper 变量中保存的是程序员自定义的且路由精确(没有通配符)的 ServletServlet 匹配的路径存在 name 变量中。
contextVersion.wildcardWrapper 变量中保存的是程序员自定义的且有通配符的 ServletServlet 匹配的路径存在 name 变量中。

然后按照下面的顺序进行判断(在源码的注释中也写的很清楚了),一旦匹配成功就不会再进行后面的匹配了:

  1. 先精确匹配,判断 servletPath 是否有和程序员自定义的且路由精确的 servlet 路径是一样的,如果是则调用对应的 Servlet 进行处理。
  2. 再通配符匹配,判断 servletPath 是否有和程序员自定义的且是通配符匹配的 servlet 路径是一样的,如果是则调用对应的 Servlet 进行处理。
  3. 再判断 servletPath 的后缀是否为 jsp 或者 jspx ,如果是则调用 JspServlet 进行处理。
  4. 再判断 servletPath 是否 Tomcat 的欢迎界面。
  5. 如果上述条件都不满足,就调用 DefaultServlet 来当作静态文件处理,这就匹配了前面对 DefaultServlet 中会读 webapps 目录下的文件的解释。

总结

到这里就很清楚了,第一次文件读取攻击是通过触发 Tomcat 调用 DefaultServlet 处理,来读取并返回攻击者指定的任意 webapps 目录下的文件,从而可以读取网页在 WEB-INF 目录下的源码。第二次文件包含攻击是通过修改 AJP 数据包中的 URI 字段以 jsp 结尾,触发 Tomcat 调用 JspServlet 处理,来编译攻击者指定的任意文件,从而命令执行。
而两者的造成都是因为 AjpProcessor#prepareRequest() 方法中对 requestattributes 赋值没有任何限制,造成黑客在 Tomcat 调用 DefaultServletJspServlet 进行处理的时候携带了恶意的参数。

漏洞修复

AjpProcessor#prepareRequest() 方法中做了对 requestattributes 赋值的限制。现在只能对特定的 attribute 进行控制。

修复后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
else {
// All 'known' attributes will be processed by the previous
// blocks. Any remaining attribute is an 'arbitrary' one.
Pattern pattern = protocol.getAllowedRequestAttributesPatternInternal();
if (pattern != null && pattern.matcher(n).matches()) {
request.setAttribute(n, v);
} else {
log.warn(sm.getString("ajpprocessor.unknownAttribute", n));
response.setStatus(403);
setErrorState(ErrorState.CLOSE_CLEAN, null);
}
}

参考文章

https://tomcat.apache.org/connectors-doc/ajp/ajpv13a.html
https://httpd.apache.org/docs/2.2/mod/mod_proxy_ajp.html
https://www.00theway.org/2020/02/22/ajp-shooter-from-source-code-to-exploit/
https://mp.weixin.qq.com/s/GzqLkwlIQi_i3AVIXn59FQ