漏洞概述
2017年9月19日,Apache Tomcat官方确认并修复了两个高危漏洞,其中就有远程代码执行漏洞(CVE-2017-12615)。当存在漏洞的Tomcat 运行在 Windows 主机上,且启用了HTTP PUT请求方法,攻击者将有可能可通过精心构造的攻击请求数据包向服务器上传包含任意代码的 JSP 的webshell文件,JSP文件中的恶意代码将能被服务器执行,导致服务器上的数据泄露或获取服务器权限。
- 影响范围:Apache Tomcat 7.0.0 – 7.0.79
漏洞分析与复现
环境:
- Windows8.1
- Tomcat 7.0.56
- JDK 1.8.0_221
- IntelliJ IDEA 2019.1.3
- BurpSuite 2.0.11
漏洞分析
查看conf/web.xml
,可以发现tomcat
默认readonly
为true
,需要手动设置为false
才可以出触发此漏洞。
<!-- readonly Is this context "read only", so HTTP -->
<!-- commands like PUT and DELETE are -->
<!-- rejected? [true] -->
手动添加:
<init-param>
<param-name>readonly</param-name>
<param-name>false</param-name>
</init-param>
但是,即使是设置了readonly
为false
,tomcat
默认也不会允许用户上传jsp
或者jspx
一类的文件,而这个涉及到Servlet
的一个处理逻辑问题。
可能有人没接触过Java Web,这里提一下Servlet和JSP的定义:
“Servlet”是“Server Applet”的缩写,意为“小服务程序”或者“服务连接器”。
广义上说,Servlet是Java的一个接口类;狭义上说,Servlet是指实现这个接口的类。
JSP(Java Server Pages,Java服务器页面)是一种类似于PHP的东西,其本质是Servlet(JSP在第一次访问的时候会被翻译成Servlet,再编译成.class执行)。
在默认情况下,tomcat
使用org.apache.catalina.servlets.JspServlet
类来处理后缀是jsp
或者是jspx
的请求,而PUT
、DELETE
等HTTP
操作和其他请求都是由org.apache.catalina.servlets.DefaultServlet
来实现的。
所以,因为JspServlet
类中没有PUT
上传的逻辑,所以不能直接触发。而这个漏洞实际上是通过构造特殊的文件后缀名来绕过tomcat
的检测,改用DefaultServlet
处理恶意的PUT
请求,从而上传jsp
的webshell
。
下载tomcat
的源码,打开DefaultServlet
类,可以看到doPut()
方法。
Servlet中的doXxx()方法是重写HttpServlet中的方法,例如doGet(),doPost()等等。只有在重写了某个方法之后,这个Servlet才能支持对应方式的请求,否则在请求的时候就会报405。
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (readOnly) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
可以看到,要启用这个doPut()
方法,必须设置readOnly
为false
。如果是true
就直接报错了。
第578~582行是写入文件的部分:
if (exists) {
resources.rebind(path, newResource);
} else {
resources.bind(path, newResource);
}
一番寻找后发现这个bind()
和rebind()
方法都在FileDirContent.java
文件中,查看bind()
方法的源码,实际上也是调用了rebind()
方法:
@Override
public void bind(String name, Object obj, Attributes attrs)
throws NamingException {
// Note: No custom attributes allowed
File file = new File(base, name);
if (file.exists())
throw new NameAlreadyBoundException
(sm.getString("resources.alreadyBound", name));
rebind(name, obj, attrs);
}
查看rebind()
方法的源码,其写入文件的核心部分如下:
File file = new File(base, name);
InputStream is = null;
if (obj instanceof Resource) {
try {
is = ((Resource) obj).streamContent();
} catch (IOException e) {
// Ignore
}
} else if (obj instanceof InputStream) {
is = (InputStream) obj;
} else if (obj instanceof DirContext) {
if (file.exists()) {
if (!file.delete())
throw new NamingException
(sm.getString("resources.bindFailed", name));
}
if (!file.mkdir())
throw new NamingException
(sm.getString("resources.bindFailed", name));
}
if (is == null)
throw new NamingException
(sm.getString("resources.bindFailed", name));
// Open os
try {
FileOutputStream os = null;
byte buffer[] = new byte[BUFFER_SIZE];
int len = -1;
try {
os = new FileOutputStream(file);
while (true) {
len = is.read(buffer);
if (len == -1)
break;
os.write(buffer, 0, len);
}
} finally {
if (os != null)
os.close();
is.close();
}
真正写入文件是使用FileOutputStream
来写入的。而创建文件当然是使用了Java
的File
类。
查看其源码:
public File(String pathname) {
if (pathname == null) {
throw new NullPointerException();
}
this.path = fs.normalize(pathname);
this.prefixLength = fs.prefixLength(this.path);
}
这里有一个normalize()
方法比较可疑,进去看看:
public abstract String normalize(String path);
这是个抽象方法,在一个接口里。它的其中一个实现如下:
@Override
public String normalize(String path) {
int n = path.length();
char slash = this.slash;
char altSlash = this.altSlash;
char prev = 0;
for (int i = 0; i < n; i++) {
char c = path.charAt(i);
if (c == altSlash)
return normalize(path, n, (prev == slash) ? i - 1 : i);
if ((c == slash) && (prev == slash) && (i > 1))
return normalize(path, n, i - 1);
if ((c == ':') && (i > 1))
return normalize(path, n, 0);
prev = c;
}
if (prev == slash) return normalize(path, n, n - 1);
return path;
}
在第10行可以看到,如果文件名后面有“/”,会将其去掉。所以,使用以下文件后缀名可以成功写入.jsp
文件:
- .jsp/
这是Java
本身处理逻辑的问题,与使用的操作系统无关。所以,这样构造文件后缀名,就可以成功利用这个漏洞。
而实际上还有两种后缀名也可以成功写入,但仅限于操作系统是Windows
的条件下:
- evil.jsp%20
- evil.jsp::$DATA
要知道为什么这两种文件名也可以,就得继续跟进,查看FileOutputStream
类的源码,看看它具体是怎样创建一个文件的。
这个类的其中一个构造器如下:
public FileOutputStream(File file, boolean append)
throws FileNotFoundException
{
String name = (file != null ? file.getPath() : null);
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkWrite(name);
}
if (name == null) {
throw new NullPointerException();
}
if (file.isInvalid()) {
throw new FileNotFoundException("Invalid file path");
}
this.fd = new FileDescriptor();
fd.attach(this);
this.append = append;
this.path = name;
open(name, append);
}
写入文件是使用的这个open()
方法:
private void open(String name, boolean append)
throws FileNotFoundException {
open0(name, append);
}
这个open()
方法又调用了open0()
方法:
private native void open0(String name, boolean append) throws FileNotFoundException;
从这个native
关键字可以看出,这个方法并不是使用Java
实现的。实际上,使用了native
关键字表明这是一个JNI(Java Native Interface)
方法,这里调用的是JVM
底层实现的C
代码。
然而我太菜了,并不懂JVM
的底层实现以及怎么分析它的源码,只好借用了网上的一张图。最终创建文件是使用的图上这个CreateFileW()
函数:
这个CreateFileW()
函数实际上是Windows API
的一部分,所以归根结底还是Windows
对于文件名处理的问题。
这也解释了以下两种后缀名最后都会被处理为.jsp
:
- .jsp%20
- .jsp::$DATA
漏洞复现
将readOnly
手动设置为false
之后,开启tomcat
,使用PUT
方法上传一个文件。
先改一下tomcat
的端口以免和BurpSuite冲突:
<Connector port="9090" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
构造请求并用BurpSuite抓包修改:
访问webapps/ROOT
目录,发现shell.jsp
文件。
访问shell.jsp
,可以发现成功访问。
修补方案
- 在不需要用到
PUT
请求时,将readonly
属性设置为true
,避免文件上传操作。 - 升级
tomcat
。