原文链接https://www.exploit-db.com/papers/46045

系统地搜索PHP disable_functions绕过

过去几天,在 imap_open 函数 (CVE-2018-19518中发现了一个漏洞。这个漏洞的主要影响是,本地用户可以使用这个漏洞,绕过经过加固的服务器中的一些限制,并执行OS命令,因为这个功能通常是允许的。这种绕过方式与ShellShock 类似:可以在disable_functions不禁止的函数中注入OS命令。我想找到一种自动发现类似绕过的方法。

分步解决这个问题

  1. 抽取PHP 函数的每个参数
  2. 对于每个函数使用正确的参数 执行 跟踪调用
  3. 在跟踪的过程中寻找每个可能的危险调用

当然这是一种幼稚的方法,但是同样的工作可以在fuzzing的时候被重用,所以我想这不是一种无用的工作。

抽取参数

步骤一中最重要的事情是用一种可行的方法正确地猜测或识别每个函数所使用的参数。当然我们可以从公开的PHP文档上寻找,但是一些函数的参数没有被文档记录,或者文档仅仅记为mixed。这是很重要的如果函数不能被正确的调用,我们的跟踪就会错过潜在的危险函数调用或者调用。

有不同的方式完成识别。各有特色。我们需要组合使用最大化的发现参数和他们的类型。

有一个特别方便的方法,使用类 ReflectionFunction 。通过这个简单的类,我们可以从PHP中获得每个可用的函数的名称和参数,但是他的缺点是我们不知道真正的类型,我们只能区分字符串和数组。举例如下

<?php
    //Get all defined functions in a lazy way
    $all = get_defined_functions();
    //Discard annoying functions
    //From https://github.com/nikic/Phuzzy
    $bad = array('sleep', 'usleep', 'time_nanosleep', 'time_sleep_until','pcntl_sigwaitinfo', 'pcntl_sigtimedwait','readline', 'readline_read_history','dns_get_record','posix_kill', 'pcntl_alarm','set_magic_quotes_runtime','readline_callback_handler_install',);
    $all = array_diff($all["internal"], $bad);

    foreach ($all as $function) {
        $parameters = "$function ";
        $f = new ReflectionFunction($function);
        foreach ($f->getParameters() as $param) {
            if ($param->isArray()) {
                $parameters .=  "ARRAY ";
            } else {
                $parameters .= "STRING ";
            }
        }
        echo substr($parameters, 0, -1);
        echo "\n";
    }
    ?>

这段代码生成了一个函数列表,我们可以稍后解析这些函数来生成我们要跟踪调用的测试:

json_last_error_msg
    spl_classes
    spl_autoload STRING STRING
    spl_autoload_extensions STRING
    spl_autoload_register STRING STRING STRING
    spl_autoload_unregister STRING
    spl_autoload_functions
    spl_autoload_call STRING
    class_parents STRING STRING
    class_implements STRING STRING
    class_uses STRING STRING
    spl_object_hash STRING
    spl_object_id STRING
    iterator_to_array STRING STRING
    iterator_count STRING
    iterator_apply STRING STRING ARRAY

更好的方法是将PHP内部用于解析参数的方法进行hook,就像这篇文章中说的"使用frida寻找PHP内置函数中隐藏的参数"Hunting for hidden parameters within PHP built-in functions (using frida)。作者用FRIDA hook 了 "zend_parse_parameters" 函数,而且解析了验证参数传递的模式。关于FRIDA的文章,Hacking a game to learn FRIDA basics (Pwn Adventure 3))。这个方式使最好的方式,因为通过这个模式我们可以准确知道参数类型,但是缺点是,这个功能正在被抛弃,未来也不会再用了。

PHP7 和PHP5内部结构不一样,一些参数解析的API收到这些的影响。旧的API是基于字符串的,新的API是基于macros。有了zend_parse_parameters函数,我们就有了宏ZEND_PARSE_PARAMETERS_START和他的系列。有关PHP如何解析参数可以查看文档Zend Parameter Parsing (ZPP API)。基本上现在不能简单的志勇FRIDA来完成hook关键函数这件工作了。

如果你记得,在我们的文章中Improving PHP extensions as a persistence method,我们看到了使用新的ZPP API解析了 md5 函数的参数。

ZEND_PARSE_PARAMETERS_START(1, 2)
    Z_PARAM_STR(arg)
    Z_PARAM_OPTIONAL
    Z_PARAM_BOOL(raw_output)
ZEND_PARSE_PARAMETERS_END();

为了抽取参数,一个破旧而有效的方式是使用符号编译PHP,并且在GDB中使用脚本来解析这些信息。但是明显有比使用GDB更好的方法,但是最近我不得不在GDB中写一些调试PHP的帮助程序。所以我使用了这个方法,开始来编译最新的PHP版本

cd /tmp
    wget http://am1.php.net/distributions/php-$(wget -qO- http://php.net/downloads.php | grep -m 1 h3 | cut -d '"' -f 2 | cut -d "v" -f 2).tar.gz
    tar xvf php*.tar.gz
    rm php*.tar.gz
    cd php*
    ./configure CFLAGS="-g -O0"
    make -j10
    sudo make install

我们的GDB脚本工作如下

  1. 执行 list functionName
  2. 如果 ZEND_PARSE_PARAMETERS_END 不存在,请增加列表中要显示的行数并重试。
  3. 如果已经存在, 就把宏 macros …_START…_END中的行抽出来
  4. 解析这两个关键字中间的参数

以下是代码

# When I do things like this I feel really bad
    # Satanism courtesy of @TheXC3LL

    class zifArgs(gdb.Command):
        "Show PHP parameters used by a function when it uses PHP 7 ZPP API. Symbols needed."

        def __init__(self):
            super (zifArgs, self).__init__("zifargs", gdb.COMMAND_SUPPORT, gdb.COMPLETE_NONE, True)

        def invoke (self, arg, from_tty):
            size = 10
            while True:
                try:
                    sourceLines = gdb.execute("list zif_" + arg, to_string=True)
                except:
                    try:
                        sourceLines = gdb.execute("list php_" + arg, to_string=True)
                    except:
                        try:
                            sourceLines = gdb.execute("list php_if_" + arg, to_string=True)
                        except:
                            print("\033[31m\033[1mFunction " + arg + " not defined!\033[0m")
                            return
                if "ZEND_PARSE_PARAMETERS_END" not in sourceLines:
                    size += 10
                    gdb.execute("set listsize " + str(size))
                else:
                    gdb.execute("set listsize 10")
                    break
            try:
                chunk = sourceLines[sourceLines.index("_START"):sourceLines.rindex("_END")].split("\n")
            except:
                print("\033[31m\033[1mParameters not found. Try zifargs_old <function>\033[0m")
                return
            params = []
            for x in chunk:
                if "Z_PARAM_ARRAY" in x:
                    params.append("\033[31mARRAY")
                if "Z_PARAM_BOOL" in x:
                    params.append("\033[32mBOOL")
                if "Z_PARAM_FUNC" in x:
                    params.append("\033[33mCALLABLE")
                if "Z_PARAM_DOUBLE" in x:
                    params.append("\033[34mDOUBLE")
                if "Z_PARAM_LONG" in x or "Z_PARAM_STRICT_LONG" in x:
                    params.append("\033[36mLONG")
                if "Z_PARAM_ZVAL" in x:
                    params.append("\033[37mMIXED")
                if "Z_PARAM_OBJECT" in x:
                    params.append("\033[38mOBJECT")
                if "Z_PARAM_RESOURCE" in x:
                    params.append("\033[39mRESOURCE")
                if "Z_PARAM_STR" in x:
                    params.append("\033[35mSTRING")
                if "Z_PARAM_CLASS" in x:
                    params.append("\033[37mCLASS")
                if "Z_PARAM_PATH" in x:
                    params.append("\033[31mPATH")
                if "Z_PARAM_OPTIONAL" in x:
                    params.append("\033[37mOPTIONAL")
            if len(params) == 0:
                print("\033[31m\033[1mParameters not found. Try zifargs_old <function> or zifargs_error <function>\033[0m")
                return
            print("\033[1m"+' '.join(params) + "\033[0m")

    zifArgs()

以下是运行结果

pwndbg: loaded 171 commands. Type pwndbg [filter] for a list.
pwndbg: created $rebase, $ida gdb functions (can be used with print/break)
[+] Stupid GDB Helper for PHP loaded! (by @TheXC3LL)
Reading symbols from php...done.
pwndbg> zifargs md5
STRING OPTIONAL BOOL
pwndbg> zifargs time
OPTIONAL LONG BOOL

每种方法都有缺点,这个天真的方法也会失败

pwndbg> zifargs array_map
CALLABLE

array_map 函数第二个参数是数组,但是我们的脚本不能检测出来。

提取参数的另一种技术是解析PHP中某些函数存在的描述性错误信息。举例array_map 将会说明需要哪些参数。

psyconauta@insulatergum:~/research/php/|
    ⇒  php -r 'array_map();'

    Warning: array_map() expects at least 2 parameters, 0 given in Command line code on line 1

如果我们把这两个参数设为字符串他会告警就会获得预期的参数类型

psyconauta@insulatergum:~/research/php/
    ⇒  php -r 'array_map("aaa","bbb");'

    Warning: array_map() expects parameter 1 to be a valid callback, function 'aaa' not found or invalid function name in Command line code on line 1

所以我们可以使用这些错误信息来推断参数

  1. 在不使用参数的情况下调用这个函数
  2. 检查错误信息中 需要多少参数
  3. 使用strings 类型填充
  4. 解析告警中期望的参数类型
  5. 换成正确的参数类型
  6. 如果还有告警,重复4

我实现了另外一个破旧的GDB命令来执行这个任务

# Don't let me use gdb when I am drunk
    # Sorry for this piece of code :(

    class zifArgsError(gdb.Command):
        "Tries to infer parameters from PHP errors"

        def __init__(self):
            super(zifArgsError, self).__init__("zifargs_error", gdb.COMMAND_SUPPORT, gdb.COMPLETE_NONE,True)

        def invoke(self, arg, from_tty):
            payload = "<?php " + arg + "();?>"
            file = open("/tmp/.zifargs", "w")
            file.write(payload)
            file.close()
            try:
                output = str(subprocess.check_output("php /tmp/.zifargs 2>&1", shell=True))
            except:
                print("\033[31m\033[1mFunction " + arg + " not defined!\033[0m")
                return
            try:
                number = output[output.index("at least ")+9:output.index("at least ")+10]
            except:
                number = output[output.index("exactly ")+8:output.index("exactly")+9]
            print("\033[33m\033[1m" + arg+ "(\033[31m" + number + "\033[33m): \033[0m")
            params = []
            infered = []
            i = 0
            while True:
                payload = "<?php " + arg + "("
                for x in range(0,int(number)-len(params)):
                    params.append("'aaa'")
                payload += ','.join(params) + "); ?>"
                file = open("/tmp/.zifargs", "w")
                file.write(payload)
                file.close()
                output = str(subprocess.check_output("php /tmp/.zifargs 2>&1", shell=True))
                #print(output)
                if "," in output:
                    separator = ","
                elif " file " in output:
                    params[i] = "/etc/passwd" # Don't run this as root, for the god sake.
                    infered.append("\033[31mPATH")
                    i +=1
                elif " in " in output:
                    separator = " in "

                try:
                    dataType = output[:output.rindex(separator)]
                    dataType = dataType[dataType.rindex(" ")+1:].lower()
                    if dataType == "array":
                        params[i] = "array('a')"
                        infered.append("\033[31mARRAY")
                    if dataType == "callback":
                        params[i] = "'var_dump'"
                        infered.append("\033[33mCALLABLE")
                    if dataType == "int":
                        params[i] = "1337"
                        infered.append("\033[36mINTEGER")
                    i += 1
                    #print(params)
                except:
                    if len(infered) > 0:
                        print("\033[1m" + ' '.join(infered) + "\033[0m")
                        return
                    else:
                        print("\033[31m\033[1mCould not retrieve parameters from " + arg + "\033[0m")
                        return

对array_map 使用的结果

pwndbg> zifargs_error array_map
    array_map(2):
    CALLABLE ARRAY

到目前为止,我们解释了可以组合使用的不同技术,以自动获得 运行每个PHP函数所需的正确参数。正如我前面所说的,这种技术也可以用于fuzzing,以便达到其他的代码段,或者运行忽略的fuzzing实例。
现在让我们看看如何使用收集到的信息。

开始分析跟踪结果

获得跟踪的最简单方法是使用知名工具,如strace和ltrace。只需几行bash,我们就可以使用函数名和参数 解析上一步中生成的日志,运行跟踪程序并将日志保存到文件中。让我们分析mail()函数生成的日志,例如:

⇒  strace -f /usr/bin/php -r 'mail("aaa","aaa","aaa","aaa");' 2>&1 | grep exe
    execve("/usr/bin/php", ["/usr/bin/php", "-r", "mail(\"aaa\",\"aaa\",\"aaa\",\"aaa\");"], [/* 28 vars */]) = 0
    [pid   471] execve("/bin/sh", ["sh", "-c", "/usr/sbin/sendmail -t -i "], [/* 28 vars */] <unfinished ...>
    [pid   471] <... execve resumed> )      = 0
    [pid   472] execve("/usr/sbin/sendmail", ["/usr/sbin/sendmail", "-t", "-i"], [/* 28 vars */]) = -1 ENOENT (No such file or directory)

你看到了吗,sendmail中使用了execve,这说明这个参数可以被用来bypass绕过 disable_functions 。只要我们被允许使用putenv 去控制LD_PRELOAD。事实上,这只是 CHANKRO 的工作方式,如果我们能够设置环境变量,我们就可以 设置 LD_PRELOAD在调用外部二进制文件时 去加载恶意文件,只需要运行脚本,等待,并执行一些greps 来检测调用情况。

结束语

自动化参数提取可能有点棘手,所以我决定写这篇文章来贡献我的一点经验。几个月前,我阅读了[这篇文章]
(http://www.libnex.org/blog/huntingforhiddenparameterswithinphpbuilt-infunctionsusingfrida),其中FRIDA用于hook zend_parse_parameters,我想为 PHP internals 的新手完善更多这方面的信息。其中 imap_open()漏洞是编写主题为:)的完美借口。

如果你觉得这篇文章很有用,或者想指出我的错误或排版错误,请随时在twitter上联系我@TheXC3LL

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖