2019-10-15 CVE-2017-12615 Tomcat远程代码执行漏洞分析与复现

漏洞概述

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默认readonlytrue,需要手动设置为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>

但是,即使是设置了readonlyfalsetomcat默认也不会允许用户上传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的请求,而PUTDELETEHTTP操作和其他请求都是由org.apache.catalina.servlets.DefaultServlet来实现的。

所以,因为JspServlet类中没有PUT上传的逻辑,所以不能直接触发。而这个漏洞实际上是通过构造特殊的文件后缀名来绕过tomcat的检测,改用DefaultServlet处理恶意的PUT请求,从而上传jspwebshell

下载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()方法,必须设置readOnlyfalse。如果是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来写入的。而创建文件当然是使用了JavaFile类。

查看其源码:

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()函数:

123.png

这个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抓包修改:

1567950905878.png

访问webapps/ROOT目录,发现shell.jsp文件。

1567950996722.png

访问shell.jsp,可以发现成功访问。

1567950933179.png

修补方案

  • 在不需要用到PUT请求时,将readonly属性设置为true,避免文件上传操作。
  • 升级tomcat

参考

https://paper.seebug.org/399/

推荐阅读更多精彩内容